From 2efaf24a04d16ffb60693cd79ece5a2f49c304fb Mon Sep 17 00:00:00 2001 From: aserrador Date: Sat, 7 Mar 2026 23:19:34 -0500 Subject: [PATCH] refactor: add Settings singleton pattern for config management --- AGENTS.md | 32 +-- src/{tryton-mcp => tryton_mcp}/__init__.py | 0 src/tryton_mcp/config.py | 42 +++ src/{tryton-mcp => tryton_mcp}/server.py | 31 +-- test_rpc.ipynb | 285 +++++++++++++++++++++ test_rpc.py | 17 +- 6 files changed, 356 insertions(+), 51 deletions(-) rename src/{tryton-mcp => tryton_mcp}/__init__.py (100%) create mode 100644 src/tryton_mcp/config.py rename src/{tryton-mcp => tryton_mcp}/server.py (63%) create mode 100644 test_rpc.ipynb diff --git a/AGENTS.md b/AGENTS.md index b5372f8..c0229c8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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" diff --git a/src/tryton-mcp/__init__.py b/src/tryton_mcp/__init__.py similarity index 100% rename from src/tryton-mcp/__init__.py rename to src/tryton_mcp/__init__.py diff --git a/src/tryton_mcp/config.py b/src/tryton_mcp/config.py new file mode 100644 index 0000000..ef1b1e9 --- /dev/null +++ b/src/tryton_mcp/config.py @@ -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() diff --git a/src/tryton-mcp/server.py b/src/tryton_mcp/server.py similarity index 63% rename from src/tryton-mcp/server.py rename to src/tryton_mcp/server.py index 8c0a90d..aa9459f 100644 --- a/src/tryton-mcp/server.py +++ b/src/tryton_mcp/server.py @@ -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() diff --git a/test_rpc.ipynb b/test_rpc.ipynb new file mode 100644 index 0000000..b3781bb --- /dev/null +++ b/test_rpc.ipynb @@ -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 +} diff --git a/test_rpc.py b/test_rpc.py index 8ded476..1a03197 100644 --- a/test_rpc.py +++ b/test_rpc.py @@ -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()