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/ TrytonMCP/
├── src/tryton-mcp/ ├── src/
│ └── tryton_mcp/
│ ├── __init__.py │ ├── __init__.py
│ ├── config.py # Settings singleton with TrytonSettings
│ └── server.py # Main MCP server with tool definitions │ └── server.py # Main MCP server with tool definitions
├── test_rpc.py # Standalone RPC test script ├── test_rpc.py # Standalone RPC test script
── pyproject.toml # Project dependencies ── pyproject.toml # Project dependencies
└── .env.example # Environment variables template
``` ```
## Architecture ## Architecture
@@ -23,16 +26,15 @@ TrytonMCP/
### Core Components ### Core Components
1. **Client Connection** (`server.py:45-47, 59-61, 73-75`) 1. **Settings** (`config.py`) - Singleton pattern for configuration management
- Creates a new `Client` instance for each tool call - Uses environment variables with defaults
- Credentials are hardcoded in `TRYTON_CREDENTIALS` dict - Provides `to_dict()` and `get_client()` methods
- Validates required fields in `__post_init__`
2. **Tools** - Functions decorated with `@mcp.tool()`: 2. **Tools** (`server.py`) - Functions decorated with `@mcp.tool()`:
- `create_schedule` (line 32-54) - Creates appointments in Naliia module - `create_schedule` - Creates appointments in Naliia module
- `find_service_centers` (line 57-69) - Searches service centers - `find_service_centers` - Searches service centers
- `find_products_and_services` (line 71-83) - Searches salable products - `find_products_and_services` - Searches salable products
3. **Context** (`server.py:20-24`) - Dataclass for managing client state (currently unused)
## Adding New Tools ## Adding New Tools
@@ -41,7 +43,7 @@ To add a new Tryton model as an MCP tool:
```python ```python
@mcp.tool() @mcp.tool()
def your_tool_name(param1: int, param2: str) -> List[dict]: def your_tool_name(param1: int, param2: str) -> List[dict]:
client = Client(**TRYTON_CREDENTIALS) client = settings.get_client()
client.connect() client.connect()
name = "model.your.model.method" 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 MCP Server for Naliia Module
""" """
import os
from sabatron_tryton_rpc_client.client import Client from sabatron_tryton_rpc_client.client import Client
from contextlib import asynccontextmanager
from dataclasses import dataclass
from fastmcp import FastMCP from fastmcp import FastMCP
from typing import List, AsyncIterator from typing import List
import datetime import datetime
from tryton_mcp.config import settings
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
mcp = FastMCP("Tryton MCP Server") mcp = FastMCP("Tryton MCP Server")
@@ -49,7 +28,7 @@ def create_schedule(
} }
] ]
client = Client(**TRYTON_CREDENTIALS) client = Client(**settings.to_dict())
client.connect() client.connect()
name = "model.naliia.schedule.create" name = "model.naliia.schedule.create"
@@ -61,7 +40,7 @@ def create_schedule(
@mcp.tool() @mcp.tool()
def find_service_centers(): def find_service_centers():
client = Client(**TRYTON_CREDENTIALS) client = Client(**settings.to_dict())
client.connect() client.connect()
@@ -74,7 +53,7 @@ def find_service_centers():
@mcp.tool() @mcp.tool()
def find_products_and_services(): def find_products_and_services():
client = Client(**TRYTON_CREDENTIALS) client = Client(**settings.to_dict())
client.connect() 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 import sys
from sabatron_tryton_rpc_client.client import Client from pathlib import Path
import datetime
client = Client( sys.path.insert(0, str(Path(__file__).parent / "src"))
hostname=os.environ.get("TRYTON_HOSTNAME", "localhost"),
database=os.environ.get("TRYTON_DATABASE", "tryton"), from tryton_mcp.config import settings
username=os.environ.get("TRYTON_USERNAME", "admin"),
password=os.environ.get("TRYTON_PASSWORD", ""), client = settings.get_client()
port=int(os.environ.get("TRYTON_PORT", 8000)),
)
client.connect() client.connect()