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:
2026-03-14 22:12:33 -05:00
parent 6154b83455
commit 861f5030f4
13 changed files with 545 additions and 199 deletions

View File

@@ -0,0 +1,4 @@
from tryton_mcp.config import settings, TrytonSettings
from tryton_mcp.server import mcp
__all__ = ["settings", "TrytonSettings", "mcp"]

View File

@@ -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):

View File

@@ -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

View 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",
]

View 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

View 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, {}],
)

View 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, {}],
)

View 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

View 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, {}],
)

View 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, {}],
)