refactor: add Settings singleton pattern for config management

This commit is contained in:
2026-03-07 23:19:34 -05:00
parent d1b0c16d03
commit 2efaf24a04
6 changed files with 356 additions and 51 deletions

View File

@@ -8,11 +8,14 @@ TrytonMCP is an MCP (Model Context Protocol) server that enables LLMs to interac
```
TrytonMCP/
├── src/tryton-mcp/
── __init__.py
└── server.py # Main MCP server with tool definitions
├── test_rpc.py # Standalone RPC test script
└── pyproject.toml # Project dependencies
├── src/
── tryton_mcp/
├── __init__.py
│ ├── config.py # Settings singleton with TrytonSettings
│ └── server.py # Main MCP server with tool definitions
├── test_rpc.py # Standalone RPC test script
├── pyproject.toml # Project dependencies
└── .env.example # Environment variables template
```
## Architecture
@@ -23,16 +26,15 @@ TrytonMCP/
### Core Components
1. **Client Connection** (`server.py:45-47, 59-61, 73-75`)
- Creates a new `Client` instance for each tool call
- Credentials are hardcoded in `TRYTON_CREDENTIALS` dict
1. **Settings** (`config.py`) - Singleton pattern for configuration management
- Uses environment variables with defaults
- Provides `to_dict()` and `get_client()` methods
- Validates required fields in `__post_init__`
2. **Tools** - Functions decorated with `@mcp.tool()`:
- `create_schedule` (line 32-54) - Creates appointments in Naliia module
- `find_service_centers` (line 57-69) - Searches service centers
- `find_products_and_services` (line 71-83) - Searches salable products
3. **Context** (`server.py:20-24`) - Dataclass for managing client state (currently unused)
2. **Tools** (`server.py`) - Functions decorated with `@mcp.tool()`:
- `create_schedule` - Creates appointments in Naliia module
- `find_service_centers` - Searches service centers
- `find_products_and_services` - Searches salable products
## Adding New Tools
@@ -41,7 +43,7 @@ To add a new Tryton model as an MCP tool:
```python
@mcp.tool()
def your_tool_name(param1: int, param2: str) -> List[dict]:
client = Client(**TRYTON_CREDENTIALS)
client = settings.get_client()
client.connect()
name = "model.your.model.method"

42
src/tryton_mcp/config.py Normal file
View File

@@ -0,0 +1,42 @@
import os
from dataclasses import dataclass, field
from sabatron_tryton_rpc_client.client import Client
@dataclass
class TrytonSettings:
hostname: str = field(
default_factory=lambda: os.environ.get("TRYTON_HOSTNAME", "localhost")
)
database: str = field(
default_factory=lambda: os.environ.get("TRYTON_DATABASE", "tryton")
)
username: str = field(
default_factory=lambda: os.environ.get("TRYTON_USERNAME", "admin")
)
password: str = field(default_factory=lambda: os.environ.get("TRYTON_PASSWORD", ""))
port: int = field(
default_factory=lambda: int(os.environ.get("TRYTON_PORT", "8000"))
)
def to_dict(self) -> dict:
return {
"hostname": self.hostname,
"database": self.database,
"username": self.username,
"password": self.password,
"port": self.port,
}
def get_client(self) -> Client:
return Client(**self.to_dict())
def __post_init__(self):
if not self.hostname:
raise ValueError("TRYTON_HOSTNAME is required")
if not self.database:
raise ValueError("TRYTON_DATABASE is required")
settings = TrytonSettings()

View File

@@ -2,33 +2,12 @@
MCP Server for Naliia Module
"""
import os
from sabatron_tryton_rpc_client.client import Client
from contextlib import asynccontextmanager
from dataclasses import dataclass
from fastmcp import FastMCP
from typing import List, AsyncIterator
from typing import List
import datetime
def get_tryton_credentials() -> dict:
return {
"hostname": os.environ.get("TRYTON_HOSTNAME", "localhost"),
"database": os.environ.get("TRYTON_DATABASE", "tryton"),
"username": os.environ.get("TRYTON_USERNAME", "admin"),
"password": os.environ.get("TRYTON_PASSWORD", ""),
"port": int(os.environ.get("TRYTON_PORT", 8000)),
}
TRYTON_CREDENTIALS = get_tryton_credentials()
@dataclass
class AppContext:
"""Application context for the MCP server"""
client: Client
from tryton_mcp.config import settings
mcp = FastMCP("Tryton MCP Server")
@@ -49,7 +28,7 @@ def create_schedule(
}
]
client = Client(**TRYTON_CREDENTIALS)
client = Client(**settings.to_dict())
client.connect()
name = "model.naliia.schedule.create"
@@ -61,7 +40,7 @@ def create_schedule(
@mcp.tool()
def find_service_centers():
client = Client(**TRYTON_CREDENTIALS)
client = Client(**settings.to_dict())
client.connect()
@@ -74,7 +53,7 @@ def find_service_centers():
@mcp.tool()
def find_products_and_services():
client = Client(**TRYTON_CREDENTIALS)
client = Client(**settings.to_dict())
client.connect()

285
test_rpc.ipynb Normal file
View File

@@ -0,0 +1,285 @@
{
"cells": [
{
"cell_type": "code",
"execution_count": 1,
"id": "f1899961",
"metadata": {},
"outputs": [],
"source": [
"from sabatron_tryton_rpc_client.client import Client\n",
"\n",
"client = Client(\n",
" hostname='dev.naliia.co',\n",
" database='capacitacion',\n",
" username='admin',\n",
" password='admin'\n",
")\n",
"\n",
"client.connect()"
]
},
{
"cell_type": "code",
"execution_count": 3,
"id": "dfb1eac0",
"metadata": {},
"outputs": [],
"source": [
"import datetime\n",
"\n",
"# [{}]\n",
"example_schedule = [{\n",
" 'professional': 6,\n",
" 'description': \"Bien Bonito Todo!\",\n",
" 'customer': 4,\n",
" 'date': datetime.datetime(2026, 1, 29, 14, 15),\n",
" 'service_center': 11\n",
"}]"
]
},
{
"cell_type": "code",
"execution_count": 4,
"id": "29e77a34",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"[{'id': 1, 'name': 'Spring Nails', 'code': '1'}]"
]
},
"execution_count": 4,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"name = 'model.party.party.read'\n",
"args = ([1], ['id', 'name', 'code'], {})\n",
"\n",
"client.call(name, args)"
]
},
{
"cell_type": "code",
"execution_count": 5,
"id": "c0546146",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"[41]"
]
},
"execution_count": 5,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"name = \"model.naliia.schedule.create\"\n",
"args = [example_schedule, {}]\n",
"\n",
"client.call(name, args)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "08405a63",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"[{'id': 11,\n",
" 'name': 'Campo Amor',\n",
" 'address.': {'id': 1, 'street': 'Carrera 54 # 7-15, Campo Amor'}}]"
]
},
"execution_count": 135,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"name = \"model.naliia.service_center.search_read\"\n",
"args = [[[]], 0, None, None, ['name','address.street'], {}]\n",
"\n",
"client.call(name, args)"
]
},
{
"cell_type": "code",
"execution_count": 139,
"id": "eaa93853",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"[{'description': None,\n",
" 'id': 1,\n",
" 'name': 'Acrilico Esculpido',\n",
" 'list_price': Decimal('98000')},\n",
" {'description': None,\n",
" 'id': 2,\n",
" 'name': 'Base Ruber',\n",
" 'list_price': Decimal('68000')},\n",
" {'description': None,\n",
" 'id': 3,\n",
" 'name': 'Dipping',\n",
" 'list_price': Decimal('63000')},\n",
" {'description': None,\n",
" 'id': 4,\n",
" 'name': 'Forrado en acrilico',\n",
" 'list_price': Decimal('78000')},\n",
" {'description': None,\n",
" 'id': 5,\n",
" 'name': 'Manos semipermanente',\n",
" 'list_price': Decimal('48000')},\n",
" {'description': None,\n",
" 'id': 6,\n",
" 'name': 'Manos semipermanente con decorado',\n",
" 'list_price': Decimal('48000')},\n",
" {'description': '',\n",
" 'id': 28,\n",
" 'name': 'Manos semipermanentes sin decordo hombre',\n",
" 'list_price': Decimal('33000')},\n",
" {'description': None,\n",
" 'id': 8,\n",
" 'name': 'Manos tradicionales',\n",
" 'list_price': Decimal('28000')},\n",
" {'description': None,\n",
" 'id': 29,\n",
" 'name': 'Manos tradicionales',\n",
" 'list_price': Decimal('25000')},\n",
" {'description': None,\n",
" 'id': 9,\n",
" 'name': 'Pestalas efecto natural',\n",
" 'list_price': Decimal('95000')},\n",
" {'description': None,\n",
" 'id': 10,\n",
" 'name': 'Pestañas 2D',\n",
" 'list_price': Decimal('110000')},\n",
" {'description': None,\n",
" 'id': 12,\n",
" 'name': 'Pestañas efecto pestañina',\n",
" 'list_price': Decimal('105000')},\n",
" {'description': None,\n",
" 'id': 13,\n",
" 'name': 'Pestañas tecnológicas',\n",
" 'list_price': Decimal('120000')},\n",
" {'description': None,\n",
" 'id': 14,\n",
" 'name': 'Pies semipermanentes',\n",
" 'list_price': Decimal('38000')},\n",
" {'description': None,\n",
" 'id': 30,\n",
" 'name': 'Pies semipermanentes',\n",
" 'list_price': Decimal('36000')},\n",
" {'description': None,\n",
" 'id': 15,\n",
" 'name': 'Pies tradicionales',\n",
" 'list_price': Decimal('30000')},\n",
" {'description': None,\n",
" 'id': 31,\n",
" 'name': 'Pies tradicionales',\n",
" 'list_price': Decimal('30000')},\n",
" {'description': None,\n",
" 'id': 16,\n",
" 'name': 'Poly gel',\n",
" 'list_price': Decimal('98000')},\n",
" {'description': None,\n",
" 'id': 17,\n",
" 'name': 'Press on',\n",
" 'list_price': Decimal('88000')},\n",
" {'description': None,\n",
" 'id': 18,\n",
" 'name': 'Retiro Forrado en acrílico',\n",
" 'list_price': Decimal('10000')},\n",
" {'description': None,\n",
" 'id': 19,\n",
" 'name': 'Retiro Press on',\n",
" 'list_price': Decimal('20000')},\n",
" {'description': None,\n",
" 'id': 33,\n",
" 'name': 'Retiro Press on',\n",
" 'list_price': Decimal('10000')},\n",
" {'description': None,\n",
" 'id': 20,\n",
" 'name': 'Retiro semipermanente',\n",
" 'list_price': Decimal('10000')},\n",
" {'description': None,\n",
" 'id': 32,\n",
" 'name': 'Retiro semipermanente',\n",
" 'list_price': Decimal('5000')},\n",
" {'description': None,\n",
" 'id': 21,\n",
" 'name': 'Retoque Acrílico esculpido',\n",
" 'list_price': Decimal('78000')},\n",
" {'description': None,\n",
" 'id': 22,\n",
" 'name': 'Retoque de pestañas 15 días',\n",
" 'list_price': Decimal('53000')},\n",
" {'description': None,\n",
" 'id': 26,\n",
" 'name': 'Retoque de pestañas 20 días',\n",
" 'list_price': Decimal('63000')},\n",
" {'description': None,\n",
" 'id': 23,\n",
" 'name': 'Retoque de pestañas tecnológicas y 2D 15 días',\n",
" 'list_price': Decimal('60000')},\n",
" {'description': None,\n",
" 'id': 27,\n",
" 'name': 'Retoque de pestañas tecnológicas y 2D 20 días',\n",
" 'list_price': Decimal('70000')},\n",
" {'description': None,\n",
" 'id': 24,\n",
" 'name': 'Retoque Forrado en acrílico',\n",
" 'list_price': Decimal('68000')},\n",
" {'description': None,\n",
" 'id': 25,\n",
" 'name': 'Retoque Press on',\n",
" 'list_price': Decimal('68000')}]"
]
},
"execution_count": 139,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"name = \"model.product.product.search_read\"\n",
"args = [[['active', '=', True], ['salable', '=', True]], 0, None, None, ['name','list_price', 'description'], {'company': 1}]\n",
"\n",
"client.call(name, args)\n",
"\n"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "TrytonMCP",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.14.3"
}
},
"nbformat": 4,
"nbformat_minor": 5
}

View File

@@ -1,14 +1,11 @@
import os
from sabatron_tryton_rpc_client.client import Client
import datetime
import sys
from pathlib import Path
client = Client(
hostname=os.environ.get("TRYTON_HOSTNAME", "localhost"),
database=os.environ.get("TRYTON_DATABASE", "tryton"),
username=os.environ.get("TRYTON_USERNAME", "admin"),
password=os.environ.get("TRYTON_PASSWORD", ""),
port=int(os.environ.get("TRYTON_PORT", 8000)),
)
sys.path.insert(0, str(Path(__file__).parent / "src"))
from tryton_mcp.config import settings
client = settings.get_client()
client.connect()