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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user