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

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