mseep-odoo-mcp 0.0.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mseep_odoo_mcp-0.0.3.dist-info/METADATA +270 -0
- mseep_odoo_mcp-0.0.3.dist-info/RECORD +10 -0
- mseep_odoo_mcp-0.0.3.dist-info/WHEEL +5 -0
- mseep_odoo_mcp-0.0.3.dist-info/entry_points.txt +2 -0
- mseep_odoo_mcp-0.0.3.dist-info/licenses/LICENSE +21 -0
- mseep_odoo_mcp-0.0.3.dist-info/top_level.txt +1 -0
- odoo_mcp/__init__.py +7 -0
- odoo_mcp/__main__.py +54 -0
- odoo_mcp/odoo_client.py +446 -0
- odoo_mcp/server.py +444 -0
@@ -0,0 +1,270 @@
|
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: mseep-odoo-mcp
|
3
|
+
Version: 0.0.3
|
4
|
+
Summary: MCP Server for Odoo Integration
|
5
|
+
Home-page:
|
6
|
+
Author: mseep
|
7
|
+
Author-email: Lê Anh Tuấn <justin.le.1105@gmail.com>
|
8
|
+
License: MIT
|
9
|
+
Project-URL: Homepage, https://github.com/tuanle96/mcp-odoo
|
10
|
+
Project-URL: Issues, https://github.com/tuanle96/mcp-odoo/issues
|
11
|
+
Keywords: odoo,mcp,server
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
15
|
+
Classifier: Operating System :: OS Independent
|
16
|
+
Requires-Python: >=3.6
|
17
|
+
Description-Content-Type: text/markdown
|
18
|
+
License-File: LICENSE
|
19
|
+
Requires-Dist: mcp>=0.1.1
|
20
|
+
Requires-Dist: requests>=2.31.0
|
21
|
+
Requires-Dist: xmlrpc>=0.4.1
|
22
|
+
Provides-Extra: dev
|
23
|
+
Requires-Dist: black; extra == "dev"
|
24
|
+
Requires-Dist: isort; extra == "dev"
|
25
|
+
Requires-Dist: mypy; extra == "dev"
|
26
|
+
Requires-Dist: ruff; extra == "dev"
|
27
|
+
Requires-Dist: build; extra == "dev"
|
28
|
+
Requires-Dist: twine; extra == "dev"
|
29
|
+
Dynamic: author
|
30
|
+
Dynamic: license-file
|
31
|
+
Dynamic: requires-python
|
32
|
+
|
33
|
+
# Odoo MCP Server
|
34
|
+
|
35
|
+
An MCP server implementation that integrates with Odoo ERP systems, enabling AI assistants to interact with Odoo data and functionality through the Model Context Protocol.
|
36
|
+
|
37
|
+
## Features
|
38
|
+
|
39
|
+
* **Comprehensive Odoo Integration**: Full access to Odoo models, records, and methods
|
40
|
+
* **XML-RPC Communication**: Secure connection to Odoo instances via XML-RPC
|
41
|
+
* **Flexible Configuration**: Support for config files and environment variables
|
42
|
+
* **Resource Pattern System**: URI-based access to Odoo data structures
|
43
|
+
* **Error Handling**: Clear error messages for common Odoo API issues
|
44
|
+
* **Stateless Operations**: Clean request/response cycle for reliable integration
|
45
|
+
|
46
|
+
## Tools
|
47
|
+
|
48
|
+
* **search_records**
|
49
|
+
* Search for records in any Odoo model
|
50
|
+
* Inputs:
|
51
|
+
* `model` (string): The model name (e.g., 'res.partner')
|
52
|
+
* `domain` (array): Search domain (e.g., [['is_company', '=', true]])
|
53
|
+
* `fields` (optional array): Optional fields to fetch
|
54
|
+
* `limit` (optional number): Maximum number of records to return
|
55
|
+
* Returns: Matching records with requested fields
|
56
|
+
|
57
|
+
* **read_record**
|
58
|
+
* Read details of a specific record
|
59
|
+
* Inputs:
|
60
|
+
* `model` (string): The model name (e.g., 'res.partner')
|
61
|
+
* `id` (number): The record ID
|
62
|
+
* `fields` (optional array): Optional fields to fetch
|
63
|
+
* Returns: Record data with requested fields
|
64
|
+
|
65
|
+
* **create_record**
|
66
|
+
* Create a new record in Odoo
|
67
|
+
* Inputs:
|
68
|
+
* `model` (string): The model name (e.g., 'res.partner')
|
69
|
+
* `values` (object): Dictionary of field values
|
70
|
+
* Returns: Dictionary with the new record ID
|
71
|
+
|
72
|
+
* **update_record**
|
73
|
+
* Update an existing record
|
74
|
+
* Inputs:
|
75
|
+
* `model` (string): The model name (e.g., 'res.partner')
|
76
|
+
* `id` (number): The record ID
|
77
|
+
* `values` (object): Dictionary of field values to update
|
78
|
+
* Returns: Dictionary indicating success
|
79
|
+
|
80
|
+
* **delete_record**
|
81
|
+
* Delete a record from Odoo
|
82
|
+
* Inputs:
|
83
|
+
* `model` (string): The model name (e.g., 'res.partner')
|
84
|
+
* `id` (number): The record ID
|
85
|
+
* Returns: Dictionary indicating success
|
86
|
+
|
87
|
+
* **execute_method**
|
88
|
+
* Execute a custom method on an Odoo model
|
89
|
+
* Inputs:
|
90
|
+
* `model` (string): The model name (e.g., 'res.partner')
|
91
|
+
* `method` (string): Method name to execute
|
92
|
+
* `args` (optional array): Positional arguments
|
93
|
+
* `kwargs` (optional object): Keyword arguments
|
94
|
+
* Returns: Dictionary with the method result
|
95
|
+
|
96
|
+
* **get_model_fields**
|
97
|
+
* Get field definitions for a model
|
98
|
+
* Inputs:
|
99
|
+
* `model` (string): The model name (e.g., 'res.partner')
|
100
|
+
* Returns: Dictionary with field definitions
|
101
|
+
|
102
|
+
* **search_employee**
|
103
|
+
* Search for employees by name.
|
104
|
+
* Inputs:
|
105
|
+
* `name` (string): The name (or part of the name) to search for.
|
106
|
+
* `limit` (optional number): The maximum number of results to return (default 20).
|
107
|
+
* Returns: List of matching employee names and IDs.
|
108
|
+
|
109
|
+
* **search_holidays**
|
110
|
+
* Searches for holidays within a specified date range.
|
111
|
+
* Inputs:
|
112
|
+
* `start_date` (string): Start date in YYYY-MM-DD format.
|
113
|
+
* `end_date` (string): End date in YYYY-MM-DD format.
|
114
|
+
* `employee_id` (optional number): Optional employee ID to filter holidays.
|
115
|
+
* Returns: List of holidays found.
|
116
|
+
|
117
|
+
## Resources
|
118
|
+
|
119
|
+
* **odoo://models**
|
120
|
+
* Lists all available models in the Odoo system
|
121
|
+
* Returns: JSON array of model information
|
122
|
+
|
123
|
+
* **odoo://model/{model_name}**
|
124
|
+
* Get information about a specific model including fields
|
125
|
+
* Example: `odoo://model/res.partner`
|
126
|
+
* Returns: JSON object with model metadata and field definitions
|
127
|
+
|
128
|
+
* **odoo://record/{model_name}/{record_id}**
|
129
|
+
* Get a specific record by ID
|
130
|
+
* Example: `odoo://record/res.partner/1`
|
131
|
+
* Returns: JSON object with record data
|
132
|
+
|
133
|
+
* **odoo://search/{model_name}/{domain}**
|
134
|
+
* Search for records that match a domain
|
135
|
+
* Example: `odoo://search/res.partner/[["is_company","=",true]]`
|
136
|
+
* Returns: JSON array of matching records (limited to 10 by default)
|
137
|
+
|
138
|
+
## Configuration
|
139
|
+
|
140
|
+
### Odoo Connection Setup
|
141
|
+
|
142
|
+
1. Create a configuration file named `odoo_config.json`:
|
143
|
+
|
144
|
+
```json
|
145
|
+
{
|
146
|
+
"url": "https://your-odoo-instance.com",
|
147
|
+
"db": "your-database-name",
|
148
|
+
"username": "your-username",
|
149
|
+
"password": "your-password-or-api-key"
|
150
|
+
}
|
151
|
+
```
|
152
|
+
|
153
|
+
2. Alternatively, use environment variables:
|
154
|
+
* `ODOO_URL`: Your Odoo server URL
|
155
|
+
* `ODOO_DB`: Database name
|
156
|
+
* `ODOO_USERNAME`: Login username
|
157
|
+
* `ODOO_PASSWORD`: Password or API key
|
158
|
+
* `ODOO_TIMEOUT`: Connection timeout in seconds (default: 30)
|
159
|
+
* `ODOO_VERIFY_SSL`: Whether to verify SSL certificates (default: true)
|
160
|
+
* `HTTP_PROXY`: Force the ODOO connection to use an HTTP proxy
|
161
|
+
|
162
|
+
### Usage with Claude Desktop
|
163
|
+
|
164
|
+
Add this to your `claude_desktop_config.json`:
|
165
|
+
|
166
|
+
```json
|
167
|
+
{
|
168
|
+
"mcpServers": {
|
169
|
+
"odoo": {
|
170
|
+
"command": "python",
|
171
|
+
"args": [
|
172
|
+
"-m",
|
173
|
+
"odoo_mcp"
|
174
|
+
],
|
175
|
+
"env": {
|
176
|
+
"ODOO_URL": "https://your-odoo-instance.com",
|
177
|
+
"ODOO_DB": "your-database-name",
|
178
|
+
"ODOO_USERNAME": "your-username",
|
179
|
+
"ODOO_PASSWORD": "your-password-or-api-key"
|
180
|
+
}
|
181
|
+
}
|
182
|
+
}
|
183
|
+
}
|
184
|
+
```
|
185
|
+
|
186
|
+
### Docker
|
187
|
+
|
188
|
+
```json
|
189
|
+
{
|
190
|
+
"mcpServers": {
|
191
|
+
"odoo": {
|
192
|
+
"command": "docker",
|
193
|
+
"args": [
|
194
|
+
"run",
|
195
|
+
"-i",
|
196
|
+
"--rm",
|
197
|
+
"-e",
|
198
|
+
"ODOO_URL",
|
199
|
+
"-e",
|
200
|
+
"ODOO_DB",
|
201
|
+
"-e",
|
202
|
+
"ODOO_USERNAME",
|
203
|
+
"-e",
|
204
|
+
"ODOO_PASSWORD",
|
205
|
+
"mcp/odoo"
|
206
|
+
],
|
207
|
+
"env": {
|
208
|
+
"ODOO_URL": "https://your-odoo-instance.com",
|
209
|
+
"ODOO_DB": "your-database-name",
|
210
|
+
"ODOO_USERNAME": "your-username",
|
211
|
+
"ODOO_PASSWORD": "your-password-or-api-key"
|
212
|
+
}
|
213
|
+
}
|
214
|
+
}
|
215
|
+
}
|
216
|
+
```
|
217
|
+
|
218
|
+
## Installation
|
219
|
+
|
220
|
+
### Python Package
|
221
|
+
|
222
|
+
```bash
|
223
|
+
pip install odoo-mcp
|
224
|
+
```
|
225
|
+
|
226
|
+
### Running the Server
|
227
|
+
|
228
|
+
```bash
|
229
|
+
# Using the installed package
|
230
|
+
odoo-mcp
|
231
|
+
|
232
|
+
# Using the MCP development tools
|
233
|
+
mcp dev odoo_mcp/server.py
|
234
|
+
|
235
|
+
# With additional dependencies
|
236
|
+
mcp dev odoo_mcp/server.py --with pandas --with numpy
|
237
|
+
|
238
|
+
# Mount local code for development
|
239
|
+
mcp dev odoo_mcp/server.py --with-editable .
|
240
|
+
```
|
241
|
+
|
242
|
+
## Build
|
243
|
+
|
244
|
+
Docker build:
|
245
|
+
|
246
|
+
```bash
|
247
|
+
docker build -t mcp/odoo:latest -f Dockerfile .
|
248
|
+
```
|
249
|
+
|
250
|
+
## Parameter Formatting Guidelines
|
251
|
+
|
252
|
+
When using the MCP tools for Odoo, pay attention to these parameter formatting guidelines:
|
253
|
+
|
254
|
+
1. **Domain Parameter**:
|
255
|
+
* The following domain formats are supported:
|
256
|
+
* List format: `[["field", "operator", value], ...]`
|
257
|
+
* Object format: `{"conditions": [{"field": "...", "operator": "...", "value": "..."}]}`
|
258
|
+
* JSON string of either format
|
259
|
+
* Examples:
|
260
|
+
* List format: `[["is_company", "=", true]]`
|
261
|
+
* Object format: `{"conditions": [{"field": "date_order", "operator": ">=", "value": "2025-03-01"}]}`
|
262
|
+
* Multiple conditions: `[["date_order", ">=", "2025-03-01"], ["date_order", "<=", "2025-03-31"]]`
|
263
|
+
|
264
|
+
2. **Fields Parameter**:
|
265
|
+
* Should be an array of field names: `["name", "email", "phone"]`
|
266
|
+
* The server will try to parse string inputs as JSON
|
267
|
+
|
268
|
+
## License
|
269
|
+
|
270
|
+
This MCP server is licensed under the MIT License.
|
@@ -0,0 +1,10 @@
|
|
1
|
+
mseep_odoo_mcp-0.0.3.dist-info/licenses/LICENSE,sha256=_FwB4PGxcQWsToTqX33moF0HqnB45Ox6Fr0VPJiLyBI,1071
|
2
|
+
odoo_mcp/__init__.py,sha256=fw0VD6ow8QmzQ4L8DxyneEMqC6bgAEde62nvEUqiGgQ,102
|
3
|
+
odoo_mcp/__main__.py,sha256=H_46LG58iV38bLXANKQW-Hzi5iQ7Ww34RtgIVe3YMHk,1834
|
4
|
+
odoo_mcp/odoo_client.py,sha256=tmZs_keeuRBQ77pPHpJ4f4wVCTjhZeahLZ_rZIAruNQ,14699
|
5
|
+
odoo_mcp/server.py,sha256=f5ukjDgYLTN4wL96xJWTyoMak6qF-LV6AO8bYs16PvA,14995
|
6
|
+
mseep_odoo_mcp-0.0.3.dist-info/METADATA,sha256=REwngGo1B5exHphpV8TCWgtmqZklbniZIVN2wj4VjtU,7884
|
7
|
+
mseep_odoo_mcp-0.0.3.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
|
8
|
+
mseep_odoo_mcp-0.0.3.dist-info/entry_points.txt,sha256=VAFapQ2i2fiMrM5fRcxVz1c9M3j-5RZ6QCJJT6taxQk,52
|
9
|
+
mseep_odoo_mcp-0.0.3.dist-info/top_level.txt,sha256=U9N8tvrkBgl0wl-xe8OmrJhxfpOdynRAVA7XFJIi7rk,9
|
10
|
+
mseep_odoo_mcp-0.0.3.dist-info/RECORD,,
|
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2025 Lê Anh Tuấn
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21
|
+
SOFTWARE.
|
@@ -0,0 +1 @@
|
|
1
|
+
odoo_mcp
|
odoo_mcp/__init__.py
ADDED
odoo_mcp/__main__.py
ADDED
@@ -0,0 +1,54 @@
|
|
1
|
+
"""
|
2
|
+
Command line entry point for the Odoo MCP Server
|
3
|
+
"""
|
4
|
+
import sys
|
5
|
+
import asyncio
|
6
|
+
import traceback
|
7
|
+
import os
|
8
|
+
|
9
|
+
from .server import mcp
|
10
|
+
|
11
|
+
|
12
|
+
def main() -> int:
|
13
|
+
"""
|
14
|
+
Run the MCP server
|
15
|
+
"""
|
16
|
+
try:
|
17
|
+
print("=== ODOO MCP SERVER STARTING ===", file=sys.stderr)
|
18
|
+
print(f"Python version: {sys.version}", file=sys.stderr)
|
19
|
+
print("Environment variables:", file=sys.stderr)
|
20
|
+
for key, value in os.environ.items():
|
21
|
+
if key.startswith("ODOO_"):
|
22
|
+
if key == "ODOO_PASSWORD":
|
23
|
+
print(f" {key}: ***hidden***", file=sys.stderr)
|
24
|
+
else:
|
25
|
+
print(f" {key}: {value}", file=sys.stderr)
|
26
|
+
|
27
|
+
# Check if server instance has the run_stdio method
|
28
|
+
methods = [method for method in dir(mcp) if not method.startswith('_')]
|
29
|
+
print(f"Available methods on mcp object: {methods}", file=sys.stderr)
|
30
|
+
|
31
|
+
print("Starting MCP server with run() method...", file=sys.stderr)
|
32
|
+
sys.stderr.flush() # Ensure log information is written immediately
|
33
|
+
|
34
|
+
# Use the run() method directly
|
35
|
+
mcp.run()
|
36
|
+
|
37
|
+
# If execution reaches here, the server exited normally
|
38
|
+
print("MCP server stopped normally", file=sys.stderr)
|
39
|
+
return 0
|
40
|
+
except KeyboardInterrupt:
|
41
|
+
print("MCP server stopped by user", file=sys.stderr)
|
42
|
+
return 0
|
43
|
+
except Exception as e:
|
44
|
+
print(f"Error starting server: {e}", file=sys.stderr)
|
45
|
+
print("Exception details:", file=sys.stderr)
|
46
|
+
traceback.print_exc(file=sys.stderr)
|
47
|
+
print("\nServer object information:", file=sys.stderr)
|
48
|
+
print(f"MCP object type: {type(mcp)}", file=sys.stderr)
|
49
|
+
print(f"MCP object dir: {dir(mcp)}", file=sys.stderr)
|
50
|
+
return 1
|
51
|
+
|
52
|
+
|
53
|
+
if __name__ == "__main__":
|
54
|
+
sys.exit(main())
|
odoo_mcp/odoo_client.py
ADDED
@@ -0,0 +1,446 @@
|
|
1
|
+
"""
|
2
|
+
Odoo XML-RPC client for MCP server integration
|
3
|
+
"""
|
4
|
+
|
5
|
+
import json
|
6
|
+
import os
|
7
|
+
import re
|
8
|
+
import socket
|
9
|
+
import urllib.parse
|
10
|
+
|
11
|
+
import http.client
|
12
|
+
import xmlrpc.client
|
13
|
+
|
14
|
+
|
15
|
+
class OdooClient:
|
16
|
+
"""Client for interacting with Odoo via XML-RPC"""
|
17
|
+
|
18
|
+
def __init__(
|
19
|
+
self,
|
20
|
+
url,
|
21
|
+
db,
|
22
|
+
username,
|
23
|
+
password,
|
24
|
+
timeout=10,
|
25
|
+
verify_ssl=True,
|
26
|
+
):
|
27
|
+
"""
|
28
|
+
Initialize the Odoo client with connection parameters
|
29
|
+
|
30
|
+
Args:
|
31
|
+
url: Odoo server URL (with or without protocol)
|
32
|
+
db: Database name
|
33
|
+
username: Login username
|
34
|
+
password: Login password
|
35
|
+
timeout: Connection timeout in seconds
|
36
|
+
verify_ssl: Whether to verify SSL certificates
|
37
|
+
"""
|
38
|
+
# Ensure URL has a protocol
|
39
|
+
if not re.match(r"^https?://", url):
|
40
|
+
url = f"http://{url}"
|
41
|
+
|
42
|
+
# Remove trailing slash from URL if present
|
43
|
+
url = url.rstrip("/")
|
44
|
+
|
45
|
+
self.url = url
|
46
|
+
self.db = db
|
47
|
+
self.username = username
|
48
|
+
self.password = password
|
49
|
+
self.uid = None
|
50
|
+
|
51
|
+
# Set timeout and SSL verification
|
52
|
+
self.timeout = timeout
|
53
|
+
self.verify_ssl = verify_ssl
|
54
|
+
|
55
|
+
# Setup connections
|
56
|
+
self._common = None
|
57
|
+
self._models = None
|
58
|
+
|
59
|
+
# Parse hostname for logging
|
60
|
+
parsed_url = urllib.parse.urlparse(self.url)
|
61
|
+
self.hostname = parsed_url.netloc
|
62
|
+
|
63
|
+
# Connect
|
64
|
+
self._connect()
|
65
|
+
|
66
|
+
def _connect(self):
|
67
|
+
"""Initialize the XML-RPC connection and authenticate"""
|
68
|
+
# Tạo transport với timeout phù hợp
|
69
|
+
is_https = self.url.startswith("https://")
|
70
|
+
transport = RedirectTransport(
|
71
|
+
timeout=self.timeout, use_https=is_https, verify_ssl=self.verify_ssl
|
72
|
+
)
|
73
|
+
|
74
|
+
print(f"Connecting to Odoo at: {self.url}", file=os.sys.stderr)
|
75
|
+
print(f" Hostname: {self.hostname}", file=os.sys.stderr)
|
76
|
+
print(
|
77
|
+
f" Timeout: {self.timeout}s, Verify SSL: {self.verify_ssl}",
|
78
|
+
file=os.sys.stderr,
|
79
|
+
)
|
80
|
+
|
81
|
+
# Thiết lập endpoints
|
82
|
+
self._common = xmlrpc.client.ServerProxy(
|
83
|
+
f"{self.url}/xmlrpc/2/common", transport=transport
|
84
|
+
)
|
85
|
+
self._models = xmlrpc.client.ServerProxy(
|
86
|
+
f"{self.url}/xmlrpc/2/object", transport=transport
|
87
|
+
)
|
88
|
+
|
89
|
+
# Xác thực và lấy user ID
|
90
|
+
print(
|
91
|
+
f"Authenticating with database: {
|
92
|
+
self.db}, username: {self.username}",
|
93
|
+
file=os.sys.stderr,
|
94
|
+
)
|
95
|
+
try:
|
96
|
+
print(
|
97
|
+
f"Making request to {
|
98
|
+
self.hostname}/xmlrpc/2/common (attempt 1)",
|
99
|
+
file=os.sys.stderr,
|
100
|
+
)
|
101
|
+
self.uid = self._common.authenticate(
|
102
|
+
self.db, self.username, self.password, {}
|
103
|
+
)
|
104
|
+
if not self.uid:
|
105
|
+
raise ValueError(
|
106
|
+
"Authentication failed: Invalid username or password")
|
107
|
+
except (socket.error, socket.timeout, ConnectionError, TimeoutError) as e:
|
108
|
+
print(f"Connection error: {str(e)}", file=os.sys.stderr)
|
109
|
+
raise ConnectionError(
|
110
|
+
f"Failed to connect to Odoo server: {str(e)}")
|
111
|
+
except Exception as e:
|
112
|
+
print(f"Authentication error: {str(e)}", file=os.sys.stderr)
|
113
|
+
raise ValueError(f"Failed to authenticate with Odoo: {str(e)}")
|
114
|
+
|
115
|
+
def _execute(self, model, method, *args, **kwargs):
|
116
|
+
"""Execute a method on an Odoo model"""
|
117
|
+
return self._models.execute_kw(
|
118
|
+
self.db, self.uid, self.password, model, method, args, kwargs
|
119
|
+
)
|
120
|
+
|
121
|
+
def execute_method(self, model, method, *args, **kwargs):
|
122
|
+
"""
|
123
|
+
Execute an arbitrary method on a model
|
124
|
+
|
125
|
+
Args:
|
126
|
+
model: The model name (e.g., 'res.partner')
|
127
|
+
method: Method name to execute
|
128
|
+
*args: Positional arguments to pass to the method
|
129
|
+
**kwargs: Keyword arguments to pass to the method
|
130
|
+
|
131
|
+
Returns:
|
132
|
+
Result of the method execution
|
133
|
+
"""
|
134
|
+
return self._execute(model, method, *args, **kwargs)
|
135
|
+
|
136
|
+
def get_models(self):
|
137
|
+
"""
|
138
|
+
Get a list of all available models in the system
|
139
|
+
|
140
|
+
Returns:
|
141
|
+
List of model names
|
142
|
+
|
143
|
+
Examples:
|
144
|
+
>>> client = OdooClient(url, db, username, password)
|
145
|
+
>>> models = client.get_models()
|
146
|
+
>>> print(len(models))
|
147
|
+
125
|
148
|
+
>>> print(models[:5])
|
149
|
+
['res.partner', 'res.users', 'res.company', 'res.groups', 'ir.model']
|
150
|
+
"""
|
151
|
+
try:
|
152
|
+
# First search for model IDs
|
153
|
+
model_ids = self._execute("ir.model", "search", [])
|
154
|
+
|
155
|
+
if not model_ids:
|
156
|
+
return {
|
157
|
+
"model_names": [],
|
158
|
+
"models_details": {},
|
159
|
+
"error": "No models found",
|
160
|
+
}
|
161
|
+
|
162
|
+
# Then read the model data with only the most basic fields
|
163
|
+
# that are guaranteed to exist in all Odoo versions
|
164
|
+
result = self._execute(
|
165
|
+
"ir.model", "read", model_ids, ["model", "name"])
|
166
|
+
|
167
|
+
# Extract and sort model names alphabetically
|
168
|
+
models = sorted([rec["model"] for rec in result])
|
169
|
+
|
170
|
+
# For more detailed information, include the full records
|
171
|
+
models_info = {
|
172
|
+
"model_names": models,
|
173
|
+
"models_details": {
|
174
|
+
rec["model"]: {"name": rec.get("name", "")} for rec in result
|
175
|
+
},
|
176
|
+
}
|
177
|
+
|
178
|
+
return models_info
|
179
|
+
except Exception as e:
|
180
|
+
print(f"Error retrieving models: {str(e)}", file=os.sys.stderr)
|
181
|
+
return {"model_names": [], "models_details": {}, "error": str(e)}
|
182
|
+
|
183
|
+
def get_model_info(self, model_name):
|
184
|
+
"""
|
185
|
+
Get information about a specific model
|
186
|
+
|
187
|
+
Args:
|
188
|
+
model_name: Name of the model (e.g., 'res.partner')
|
189
|
+
|
190
|
+
Returns:
|
191
|
+
Dictionary with model information
|
192
|
+
|
193
|
+
Examples:
|
194
|
+
>>> client = OdooClient(url, db, username, password)
|
195
|
+
>>> info = client.get_model_info('res.partner')
|
196
|
+
>>> print(info['name'])
|
197
|
+
'Contact'
|
198
|
+
"""
|
199
|
+
try:
|
200
|
+
result = self._execute(
|
201
|
+
"ir.model",
|
202
|
+
"search_read",
|
203
|
+
[("model", "=", model_name)],
|
204
|
+
{"fields": ["name", "model"]},
|
205
|
+
)
|
206
|
+
|
207
|
+
if not result:
|
208
|
+
return {"error": f"Model {model_name} not found"}
|
209
|
+
|
210
|
+
return result[0]
|
211
|
+
except Exception as e:
|
212
|
+
print(f"Error retrieving model info: {str(e)}", file=os.sys.stderr)
|
213
|
+
return {"error": str(e)}
|
214
|
+
|
215
|
+
def get_model_fields(self, model_name):
|
216
|
+
"""
|
217
|
+
Get field definitions for a specific model
|
218
|
+
|
219
|
+
Args:
|
220
|
+
model_name: Name of the model (e.g., 'res.partner')
|
221
|
+
|
222
|
+
Returns:
|
223
|
+
Dictionary mapping field names to their definitions
|
224
|
+
|
225
|
+
Examples:
|
226
|
+
>>> client = OdooClient(url, db, username, password)
|
227
|
+
>>> fields = client.get_model_fields('res.partner')
|
228
|
+
>>> print(fields['name']['type'])
|
229
|
+
'char'
|
230
|
+
"""
|
231
|
+
try:
|
232
|
+
fields = self._execute(model_name, "fields_get")
|
233
|
+
return fields
|
234
|
+
except Exception as e:
|
235
|
+
print(f"Error retrieving fields: {str(e)}", file=os.sys.stderr)
|
236
|
+
return {"error": str(e)}
|
237
|
+
|
238
|
+
def search_read(
|
239
|
+
self, model_name, domain, fields=None, offset=None, limit=None, order=None
|
240
|
+
):
|
241
|
+
"""
|
242
|
+
Search for records and read their data in a single call
|
243
|
+
|
244
|
+
Args:
|
245
|
+
model_name: Name of the model (e.g., 'res.partner')
|
246
|
+
domain: Search domain (e.g., [('is_company', '=', True)])
|
247
|
+
fields: List of field names to return (None for all)
|
248
|
+
offset: Number of records to skip
|
249
|
+
limit: Maximum number of records to return
|
250
|
+
order: Sorting criteria (e.g., 'name ASC, id DESC')
|
251
|
+
|
252
|
+
Returns:
|
253
|
+
List of dictionaries with the matching records
|
254
|
+
|
255
|
+
Examples:
|
256
|
+
>>> client = OdooClient(url, db, username, password)
|
257
|
+
>>> records = client.search_read('res.partner', [('is_company', '=', True)], limit=5)
|
258
|
+
>>> print(len(records))
|
259
|
+
5
|
260
|
+
"""
|
261
|
+
try:
|
262
|
+
kwargs = {}
|
263
|
+
if offset:
|
264
|
+
kwargs["offset"] = offset
|
265
|
+
if fields is not None:
|
266
|
+
kwargs["fields"] = fields
|
267
|
+
if limit is not None:
|
268
|
+
kwargs["limit"] = limit
|
269
|
+
if order is not None:
|
270
|
+
kwargs["order"] = order
|
271
|
+
|
272
|
+
result = self._execute(model_name, "search_read", domain, kwargs)
|
273
|
+
return result
|
274
|
+
except Exception as e:
|
275
|
+
print(f"Error in search_read: {str(e)}", file=os.sys.stderr)
|
276
|
+
return []
|
277
|
+
|
278
|
+
def read_records(self, model_name, ids, fields=None):
|
279
|
+
"""
|
280
|
+
Read data of records by IDs
|
281
|
+
|
282
|
+
Args:
|
283
|
+
model_name: Name of the model (e.g., 'res.partner')
|
284
|
+
ids: List of record IDs to read
|
285
|
+
fields: List of field names to return (None for all)
|
286
|
+
|
287
|
+
Returns:
|
288
|
+
List of dictionaries with the requested records
|
289
|
+
|
290
|
+
Examples:
|
291
|
+
>>> client = OdooClient(url, db, username, password)
|
292
|
+
>>> records = client.read_records('res.partner', [1])
|
293
|
+
>>> print(records[0]['name'])
|
294
|
+
'YourCompany'
|
295
|
+
"""
|
296
|
+
try:
|
297
|
+
kwargs = {}
|
298
|
+
if fields is not None:
|
299
|
+
kwargs["fields"] = fields
|
300
|
+
|
301
|
+
result = self._execute(model_name, "read", ids, kwargs)
|
302
|
+
return result
|
303
|
+
except Exception as e:
|
304
|
+
print(f"Error reading records: {str(e)}", file=os.sys.stderr)
|
305
|
+
return []
|
306
|
+
|
307
|
+
|
308
|
+
class RedirectTransport(xmlrpc.client.Transport):
|
309
|
+
"""Transport that adds timeout, SSL verification, and redirect handling"""
|
310
|
+
|
311
|
+
def __init__(
|
312
|
+
self, timeout=10, use_https=True, verify_ssl=True, max_redirects=5, proxy=None
|
313
|
+
):
|
314
|
+
super().__init__()
|
315
|
+
self.timeout = timeout
|
316
|
+
self.use_https = use_https
|
317
|
+
self.verify_ssl = verify_ssl
|
318
|
+
self.max_redirects = max_redirects
|
319
|
+
self.proxy = proxy or os.environ.get("HTTP_PROXY")
|
320
|
+
|
321
|
+
if use_https and not verify_ssl:
|
322
|
+
import ssl
|
323
|
+
|
324
|
+
self.context = ssl._create_unverified_context()
|
325
|
+
|
326
|
+
def make_connection(self, host):
|
327
|
+
if self.proxy:
|
328
|
+
proxy_url = urllib.parse.urlparse(self.proxy)
|
329
|
+
connection = http.client.HTTPConnection(
|
330
|
+
proxy_url.hostname, proxy_url.port, timeout=self.timeout
|
331
|
+
)
|
332
|
+
connection.set_tunnel(host)
|
333
|
+
else:
|
334
|
+
if self.use_https and not self.verify_ssl:
|
335
|
+
connection = http.client.HTTPSConnection(
|
336
|
+
host, timeout=self.timeout, context=self.context
|
337
|
+
)
|
338
|
+
else:
|
339
|
+
if self.use_https:
|
340
|
+
connection = http.client.HTTPSConnection(
|
341
|
+
host, timeout=self.timeout)
|
342
|
+
else:
|
343
|
+
connection = http.client.HTTPConnection(
|
344
|
+
host, timeout=self.timeout)
|
345
|
+
|
346
|
+
return connection
|
347
|
+
|
348
|
+
def request(self, host, handler, request_body, verbose):
|
349
|
+
"""Send HTTP request with retry for redirects"""
|
350
|
+
redirects = 0
|
351
|
+
while redirects < self.max_redirects:
|
352
|
+
try:
|
353
|
+
print(f"Making request to {host}{handler}", file=os.sys.stderr)
|
354
|
+
return super().request(host, handler, request_body, verbose)
|
355
|
+
except xmlrpc.client.ProtocolError as err:
|
356
|
+
if err.errcode in (301, 302, 303, 307, 308) and err.headers.get(
|
357
|
+
"location"
|
358
|
+
):
|
359
|
+
redirects += 1
|
360
|
+
location = err.headers.get("location")
|
361
|
+
parsed = urllib.parse.urlparse(location)
|
362
|
+
if parsed.netloc:
|
363
|
+
host = parsed.netloc
|
364
|
+
handler = parsed.path
|
365
|
+
if parsed.query:
|
366
|
+
handler += "?" + parsed.query
|
367
|
+
else:
|
368
|
+
raise
|
369
|
+
except Exception as e:
|
370
|
+
print(f"Error during request: {str(e)}", file=os.sys.stderr)
|
371
|
+
raise
|
372
|
+
|
373
|
+
raise xmlrpc.client.ProtocolError(
|
374
|
+
host + handler, 310, "Too many redirects", {})
|
375
|
+
|
376
|
+
|
377
|
+
def load_config():
|
378
|
+
"""
|
379
|
+
Load Odoo configuration from environment variables or config file
|
380
|
+
|
381
|
+
Returns:
|
382
|
+
dict: Configuration dictionary with url, db, username, password
|
383
|
+
"""
|
384
|
+
# Define config file paths to check
|
385
|
+
config_paths = [
|
386
|
+
"./odoo_config.json",
|
387
|
+
os.path.expanduser("~/.config/odoo/config.json"),
|
388
|
+
os.path.expanduser("~/.odoo_config.json"),
|
389
|
+
]
|
390
|
+
|
391
|
+
# Try environment variables first
|
392
|
+
if all(
|
393
|
+
var in os.environ
|
394
|
+
for var in ["ODOO_URL", "ODOO_DB", "ODOO_USERNAME", "ODOO_PASSWORD"]
|
395
|
+
):
|
396
|
+
return {
|
397
|
+
"url": os.environ["ODOO_URL"],
|
398
|
+
"db": os.environ["ODOO_DB"],
|
399
|
+
"username": os.environ["ODOO_USERNAME"],
|
400
|
+
"password": os.environ["ODOO_PASSWORD"],
|
401
|
+
}
|
402
|
+
|
403
|
+
# Try to load from file
|
404
|
+
for path in config_paths:
|
405
|
+
expanded_path = os.path.expanduser(path)
|
406
|
+
if os.path.exists(expanded_path):
|
407
|
+
with open(expanded_path, "r") as f:
|
408
|
+
return json.load(f)
|
409
|
+
|
410
|
+
raise FileNotFoundError(
|
411
|
+
"No Odoo configuration found. Please create an odoo_config.json file or set environment variables."
|
412
|
+
)
|
413
|
+
|
414
|
+
|
415
|
+
def get_odoo_client():
|
416
|
+
"""
|
417
|
+
Get a configured Odoo client instance
|
418
|
+
|
419
|
+
Returns:
|
420
|
+
OdooClient: A configured Odoo client instance
|
421
|
+
"""
|
422
|
+
config = load_config()
|
423
|
+
|
424
|
+
# Get additional options from environment variables
|
425
|
+
timeout = int(
|
426
|
+
os.environ.get("ODOO_TIMEOUT", "30")
|
427
|
+
) # Increase default timeout to 30 seconds
|
428
|
+
verify_ssl = os.environ.get("ODOO_VERIFY_SSL", "1").lower() in [
|
429
|
+
"1", "true", "yes"]
|
430
|
+
|
431
|
+
# Print detailed configuration
|
432
|
+
print("Odoo client configuration:", file=os.sys.stderr)
|
433
|
+
print(f" URL: {config['url']}", file=os.sys.stderr)
|
434
|
+
print(f" Database: {config['db']}", file=os.sys.stderr)
|
435
|
+
print(f" Username: {config['username']}", file=os.sys.stderr)
|
436
|
+
print(f" Timeout: {timeout}s", file=os.sys.stderr)
|
437
|
+
print(f" Verify SSL: {verify_ssl}", file=os.sys.stderr)
|
438
|
+
|
439
|
+
return OdooClient(
|
440
|
+
url=config["url"],
|
441
|
+
db=config["db"],
|
442
|
+
username=config["username"],
|
443
|
+
password=config["password"],
|
444
|
+
timeout=timeout,
|
445
|
+
verify_ssl=verify_ssl,
|
446
|
+
)
|
odoo_mcp/server.py
ADDED
@@ -0,0 +1,444 @@
|
|
1
|
+
"""
|
2
|
+
MCP server for Odoo integration
|
3
|
+
|
4
|
+
Provides MCP tools and resources for interacting with Odoo ERP systems
|
5
|
+
"""
|
6
|
+
|
7
|
+
import json
|
8
|
+
from contextlib import asynccontextmanager
|
9
|
+
from dataclasses import dataclass
|
10
|
+
from datetime import datetime, timedelta
|
11
|
+
from typing import Any, AsyncIterator, Dict, List, Optional, Union, cast
|
12
|
+
|
13
|
+
from mcp.server.fastmcp import Context, FastMCP
|
14
|
+
from pydantic import BaseModel, Field
|
15
|
+
|
16
|
+
from .odoo_client import OdooClient, get_odoo_client
|
17
|
+
|
18
|
+
|
19
|
+
@dataclass
|
20
|
+
class AppContext:
|
21
|
+
"""Application context for the MCP server"""
|
22
|
+
|
23
|
+
odoo: OdooClient
|
24
|
+
|
25
|
+
|
26
|
+
@asynccontextmanager
|
27
|
+
async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
|
28
|
+
"""
|
29
|
+
Application lifespan for initialization and cleanup
|
30
|
+
"""
|
31
|
+
# Initialize Odoo client on startup
|
32
|
+
odoo_client = get_odoo_client()
|
33
|
+
|
34
|
+
try:
|
35
|
+
yield AppContext(odoo=odoo_client)
|
36
|
+
finally:
|
37
|
+
# No cleanup needed for Odoo client
|
38
|
+
pass
|
39
|
+
|
40
|
+
|
41
|
+
# Create MCP server
|
42
|
+
mcp = FastMCP(
|
43
|
+
"Odoo MCP Server",
|
44
|
+
description="MCP Server for interacting with Odoo ERP systems",
|
45
|
+
dependencies=["requests"],
|
46
|
+
lifespan=app_lifespan,
|
47
|
+
)
|
48
|
+
|
49
|
+
|
50
|
+
# ----- MCP Resources -----
|
51
|
+
|
52
|
+
|
53
|
+
@mcp.resource(
|
54
|
+
"odoo://models", description="List all available models in the Odoo system"
|
55
|
+
)
|
56
|
+
def get_models() -> str:
|
57
|
+
"""Lists all available models in the Odoo system"""
|
58
|
+
odoo_client = get_odoo_client()
|
59
|
+
models = odoo_client.get_models()
|
60
|
+
return json.dumps(models, indent=2)
|
61
|
+
|
62
|
+
|
63
|
+
@mcp.resource(
|
64
|
+
"odoo://model/{model_name}",
|
65
|
+
description="Get detailed information about a specific model including fields",
|
66
|
+
)
|
67
|
+
def get_model_info(model_name: str) -> str:
|
68
|
+
"""
|
69
|
+
Get information about a specific model
|
70
|
+
|
71
|
+
Parameters:
|
72
|
+
model_name: Name of the Odoo model (e.g., 'res.partner')
|
73
|
+
"""
|
74
|
+
odoo_client = get_odoo_client()
|
75
|
+
try:
|
76
|
+
# Get model info
|
77
|
+
model_info = odoo_client.get_model_info(model_name)
|
78
|
+
|
79
|
+
# Get field definitions
|
80
|
+
fields = odoo_client.get_model_fields(model_name)
|
81
|
+
model_info["fields"] = fields
|
82
|
+
|
83
|
+
return json.dumps(model_info, indent=2)
|
84
|
+
except Exception as e:
|
85
|
+
return json.dumps({"error": str(e)}, indent=2)
|
86
|
+
|
87
|
+
|
88
|
+
@mcp.resource(
|
89
|
+
"odoo://record/{model_name}/{record_id}",
|
90
|
+
description="Get detailed information of a specific record by ID",
|
91
|
+
)
|
92
|
+
def get_record(model_name: str, record_id: str) -> str:
|
93
|
+
"""
|
94
|
+
Get a specific record by ID
|
95
|
+
|
96
|
+
Parameters:
|
97
|
+
model_name: Name of the Odoo model (e.g., 'res.partner')
|
98
|
+
record_id: ID of the record
|
99
|
+
"""
|
100
|
+
odoo_client = get_odoo_client()
|
101
|
+
try:
|
102
|
+
record_id_int = int(record_id)
|
103
|
+
record = odoo_client.read_records(model_name, [record_id_int])
|
104
|
+
if not record:
|
105
|
+
return json.dumps(
|
106
|
+
{"error": f"Record not found: {model_name} ID {record_id}"}, indent=2
|
107
|
+
)
|
108
|
+
return json.dumps(record[0], indent=2)
|
109
|
+
except Exception as e:
|
110
|
+
return json.dumps({"error": str(e)}, indent=2)
|
111
|
+
|
112
|
+
|
113
|
+
@mcp.resource(
|
114
|
+
"odoo://search/{model_name}/{domain}",
|
115
|
+
description="Search for records matching the domain",
|
116
|
+
)
|
117
|
+
def search_records_resource(model_name: str, domain: str) -> str:
|
118
|
+
"""
|
119
|
+
Search for records that match a domain
|
120
|
+
|
121
|
+
Parameters:
|
122
|
+
model_name: Name of the Odoo model (e.g., 'res.partner')
|
123
|
+
domain: Search domain in JSON format (e.g., '[["name", "ilike", "test"]]')
|
124
|
+
"""
|
125
|
+
odoo_client = get_odoo_client()
|
126
|
+
try:
|
127
|
+
# Parse domain from JSON string
|
128
|
+
domain_list = json.loads(domain)
|
129
|
+
|
130
|
+
# Set a reasonable default limit
|
131
|
+
limit = 10
|
132
|
+
|
133
|
+
# Perform search_read for efficiency
|
134
|
+
results = odoo_client.search_read(model_name, domain_list, limit=limit)
|
135
|
+
|
136
|
+
return json.dumps(results, indent=2)
|
137
|
+
except Exception as e:
|
138
|
+
return json.dumps({"error": str(e)}, indent=2)
|
139
|
+
|
140
|
+
|
141
|
+
# ----- Pydantic models for type safety -----
|
142
|
+
|
143
|
+
|
144
|
+
class DomainCondition(BaseModel):
|
145
|
+
"""A single condition in a search domain"""
|
146
|
+
|
147
|
+
field: str = Field(description="Field name to search")
|
148
|
+
operator: str = Field(
|
149
|
+
description="Operator (e.g., '=', '!=', '>', '<', 'in', 'not in', 'like', 'ilike')"
|
150
|
+
)
|
151
|
+
value: Any = Field(description="Value to compare against")
|
152
|
+
|
153
|
+
def to_tuple(self) -> List:
|
154
|
+
"""Convert to Odoo domain condition tuple"""
|
155
|
+
return [self.field, self.operator, self.value]
|
156
|
+
|
157
|
+
|
158
|
+
class SearchDomain(BaseModel):
|
159
|
+
"""Search domain for Odoo models"""
|
160
|
+
|
161
|
+
conditions: List[DomainCondition] = Field(
|
162
|
+
default_factory=list,
|
163
|
+
description="List of conditions for searching. All conditions are combined with AND operator.",
|
164
|
+
)
|
165
|
+
|
166
|
+
def to_domain_list(self) -> List[List]:
|
167
|
+
"""Convert to Odoo domain list format"""
|
168
|
+
return [condition.to_tuple() for condition in self.conditions]
|
169
|
+
|
170
|
+
|
171
|
+
class EmployeeSearchResult(BaseModel):
|
172
|
+
"""Represents a single employee search result."""
|
173
|
+
|
174
|
+
id: int = Field(description="Employee ID")
|
175
|
+
name: str = Field(description="Employee name")
|
176
|
+
|
177
|
+
|
178
|
+
class SearchEmployeeResponse(BaseModel):
|
179
|
+
"""Response model for the search_employee tool."""
|
180
|
+
|
181
|
+
success: bool = Field(description="Indicates if the search was successful")
|
182
|
+
result: Optional[List[EmployeeSearchResult]] = Field(
|
183
|
+
default=None, description="List of employee search results"
|
184
|
+
)
|
185
|
+
error: Optional[str] = Field(default=None, description="Error message, if any")
|
186
|
+
|
187
|
+
|
188
|
+
class Holiday(BaseModel):
|
189
|
+
"""Represents a single holiday."""
|
190
|
+
|
191
|
+
display_name: str = Field(description="Display name of the holiday")
|
192
|
+
start_datetime: str = Field(description="Start date and time of the holiday")
|
193
|
+
stop_datetime: str = Field(description="End date and time of the holiday")
|
194
|
+
employee_id: List[Union[int, str]] = Field(
|
195
|
+
description="Employee ID associated with the holiday"
|
196
|
+
)
|
197
|
+
name: str = Field(description="Name of the holiday")
|
198
|
+
state: str = Field(description="State of the holiday")
|
199
|
+
|
200
|
+
|
201
|
+
class SearchHolidaysResponse(BaseModel):
|
202
|
+
"""Response model for the search_holidays tool."""
|
203
|
+
|
204
|
+
success: bool = Field(description="Indicates if the search was successful")
|
205
|
+
result: Optional[List[Holiday]] = Field(
|
206
|
+
default=None, description="List of holidays found"
|
207
|
+
)
|
208
|
+
error: Optional[str] = Field(default=None, description="Error message, if any")
|
209
|
+
|
210
|
+
|
211
|
+
# ----- MCP Tools -----
|
212
|
+
|
213
|
+
|
214
|
+
@mcp.tool(description="Execute a custom method on an Odoo model")
|
215
|
+
def execute_method(
|
216
|
+
ctx: Context,
|
217
|
+
model: str,
|
218
|
+
method: str,
|
219
|
+
args: List = None,
|
220
|
+
kwargs: Optional[Dict[str, Any]] = None,
|
221
|
+
) -> Dict[str, Any]:
|
222
|
+
"""
|
223
|
+
Execute a custom method on an Odoo model
|
224
|
+
|
225
|
+
Parameters:
|
226
|
+
model: The model name (e.g., 'res.partner')
|
227
|
+
method: Method name to execute
|
228
|
+
args: Positional arguments
|
229
|
+
kwargs: Keyword arguments
|
230
|
+
|
231
|
+
Returns:
|
232
|
+
Dictionary containing:
|
233
|
+
- success: Boolean indicating success
|
234
|
+
- result: Result of the method (if success)
|
235
|
+
- error: Error message (if failure)
|
236
|
+
"""
|
237
|
+
odoo = ctx.request_context.lifespan_context.odoo
|
238
|
+
try:
|
239
|
+
args = args or []
|
240
|
+
kwargs = kwargs or {}
|
241
|
+
|
242
|
+
# Special handling for search methods like search, search_count, search_read
|
243
|
+
search_methods = ["search", "search_count", "search_read"]
|
244
|
+
if method in search_methods and args:
|
245
|
+
# Search methods usually have domain as the first parameter
|
246
|
+
# args: [[domain], limit, offset, ...] or [domain, limit, offset, ...]
|
247
|
+
normalized_args = list(
|
248
|
+
args
|
249
|
+
) # Create a copy to avoid affecting the original args
|
250
|
+
|
251
|
+
if len(normalized_args) > 0:
|
252
|
+
# Process domain in args[0]
|
253
|
+
domain = normalized_args[0]
|
254
|
+
domain_list = []
|
255
|
+
|
256
|
+
# Check if domain is wrapped unnecessarily ([domain] instead of domain)
|
257
|
+
if (
|
258
|
+
isinstance(domain, list)
|
259
|
+
and len(domain) == 1
|
260
|
+
and isinstance(domain[0], list)
|
261
|
+
):
|
262
|
+
# Case [[domain]] - unwrap to [domain]
|
263
|
+
domain = domain[0]
|
264
|
+
|
265
|
+
# Normalize domain similar to search_records function
|
266
|
+
if domain is None:
|
267
|
+
domain_list = []
|
268
|
+
elif isinstance(domain, dict):
|
269
|
+
if "conditions" in domain:
|
270
|
+
# Object format
|
271
|
+
conditions = domain.get("conditions", [])
|
272
|
+
domain_list = []
|
273
|
+
for cond in conditions:
|
274
|
+
if isinstance(cond, dict) and all(
|
275
|
+
k in cond for k in ["field", "operator", "value"]
|
276
|
+
):
|
277
|
+
domain_list.append(
|
278
|
+
[cond["field"], cond["operator"], cond["value"]]
|
279
|
+
)
|
280
|
+
elif isinstance(domain, list):
|
281
|
+
# List format
|
282
|
+
if not domain:
|
283
|
+
domain_list = []
|
284
|
+
elif all(isinstance(item, list) for item in domain) or any(
|
285
|
+
item in ["&", "|", "!"] for item in domain
|
286
|
+
):
|
287
|
+
domain_list = domain
|
288
|
+
elif len(domain) >= 3 and isinstance(domain[0], str):
|
289
|
+
# Case [field, operator, value] (not [[field, operator, value]])
|
290
|
+
domain_list = [domain]
|
291
|
+
elif isinstance(domain, str):
|
292
|
+
# String format (JSON)
|
293
|
+
try:
|
294
|
+
parsed_domain = json.loads(domain)
|
295
|
+
if (
|
296
|
+
isinstance(parsed_domain, dict)
|
297
|
+
and "conditions" in parsed_domain
|
298
|
+
):
|
299
|
+
conditions = parsed_domain.get("conditions", [])
|
300
|
+
domain_list = []
|
301
|
+
for cond in conditions:
|
302
|
+
if isinstance(cond, dict) and all(
|
303
|
+
k in cond for k in ["field", "operator", "value"]
|
304
|
+
):
|
305
|
+
domain_list.append(
|
306
|
+
[cond["field"], cond["operator"], cond["value"]]
|
307
|
+
)
|
308
|
+
elif isinstance(parsed_domain, list):
|
309
|
+
domain_list = parsed_domain
|
310
|
+
except json.JSONDecodeError:
|
311
|
+
try:
|
312
|
+
import ast
|
313
|
+
|
314
|
+
parsed_domain = ast.literal_eval(domain)
|
315
|
+
if isinstance(parsed_domain, list):
|
316
|
+
domain_list = parsed_domain
|
317
|
+
except:
|
318
|
+
domain_list = []
|
319
|
+
|
320
|
+
# Xác thực domain_list
|
321
|
+
if domain_list:
|
322
|
+
valid_conditions = []
|
323
|
+
for cond in domain_list:
|
324
|
+
if isinstance(cond, str) and cond in ["&", "|", "!"]:
|
325
|
+
valid_conditions.append(cond)
|
326
|
+
continue
|
327
|
+
|
328
|
+
if (
|
329
|
+
isinstance(cond, list)
|
330
|
+
and len(cond) == 3
|
331
|
+
and isinstance(cond[0], str)
|
332
|
+
and isinstance(cond[1], str)
|
333
|
+
):
|
334
|
+
valid_conditions.append(cond)
|
335
|
+
|
336
|
+
domain_list = valid_conditions
|
337
|
+
|
338
|
+
# Cập nhật args với domain đã chuẩn hóa
|
339
|
+
normalized_args[0] = domain_list
|
340
|
+
args = normalized_args
|
341
|
+
|
342
|
+
# Log for debugging
|
343
|
+
print(f"Executing {method} with normalized domain: {domain_list}")
|
344
|
+
|
345
|
+
result = odoo.execute_method(model, method, *args, **kwargs)
|
346
|
+
return {"success": True, "result": result}
|
347
|
+
except Exception as e:
|
348
|
+
return {"success": False, "error": str(e)}
|
349
|
+
|
350
|
+
|
351
|
+
@mcp.tool(description="Search for employees by name")
|
352
|
+
def search_employee(
|
353
|
+
ctx: Context,
|
354
|
+
name: str,
|
355
|
+
limit: int = 20,
|
356
|
+
) -> SearchEmployeeResponse:
|
357
|
+
"""
|
358
|
+
Search for employees by name using Odoo's name_search method.
|
359
|
+
|
360
|
+
Parameters:
|
361
|
+
name: The name (or part of the name) to search for.
|
362
|
+
limit: The maximum number of results to return (default 20).
|
363
|
+
|
364
|
+
Returns:
|
365
|
+
SearchEmployeeResponse containing results or error information.
|
366
|
+
"""
|
367
|
+
odoo = ctx.request_context.lifespan_context.odoo
|
368
|
+
model = "hr.employee"
|
369
|
+
method = "name_search"
|
370
|
+
|
371
|
+
args = []
|
372
|
+
kwargs = {"name": name, "limit": limit}
|
373
|
+
|
374
|
+
try:
|
375
|
+
result = odoo.execute_method(model, method, *args, **kwargs)
|
376
|
+
parsed_result = [
|
377
|
+
EmployeeSearchResult(id=item[0], name=item[1]) for item in result
|
378
|
+
]
|
379
|
+
return SearchEmployeeResponse(success=True, result=parsed_result)
|
380
|
+
except Exception as e:
|
381
|
+
return SearchEmployeeResponse(success=False, error=str(e))
|
382
|
+
|
383
|
+
|
384
|
+
@mcp.tool(description="Search for holidays within a date range")
|
385
|
+
def search_holidays(
|
386
|
+
ctx: Context,
|
387
|
+
start_date: str,
|
388
|
+
end_date: str,
|
389
|
+
employee_id: Optional[int] = None,
|
390
|
+
) -> SearchHolidaysResponse:
|
391
|
+
"""
|
392
|
+
Searches for holidays within a specified date range.
|
393
|
+
|
394
|
+
Parameters:
|
395
|
+
start_date: Start date in YYYY-MM-DD format.
|
396
|
+
end_date: End date in YYYY-MM-DD format.
|
397
|
+
employee_id: Optional employee ID to filter holidays.
|
398
|
+
|
399
|
+
Returns:
|
400
|
+
SearchHolidaysResponse: Object containing the search results.
|
401
|
+
"""
|
402
|
+
odoo = ctx.request_context.lifespan_context.odoo
|
403
|
+
|
404
|
+
# Validate date format using datetime
|
405
|
+
try:
|
406
|
+
datetime.strptime(start_date, "%Y-%m-%d")
|
407
|
+
except ValueError:
|
408
|
+
return SearchHolidaysResponse(
|
409
|
+
success=False, error="Invalid start_date format. Use YYYY-MM-DD."
|
410
|
+
)
|
411
|
+
try:
|
412
|
+
datetime.strptime(end_date, "%Y-%m-%d")
|
413
|
+
except ValueError:
|
414
|
+
return SearchHolidaysResponse(
|
415
|
+
success=False, error="Invalid end_date format. Use YYYY-MM-DD."
|
416
|
+
)
|
417
|
+
|
418
|
+
# Calculate adjusted start_date (subtract one day)
|
419
|
+
start_date_dt = datetime.strptime(start_date, "%Y-%m-%d")
|
420
|
+
adjusted_start_date_dt = start_date_dt - timedelta(days=1)
|
421
|
+
adjusted_start_date = adjusted_start_date_dt.strftime("%Y-%m-%d")
|
422
|
+
|
423
|
+
# Build the domain
|
424
|
+
domain = [
|
425
|
+
"&",
|
426
|
+
["start_datetime", "<=", f"{end_date} 22:59:59"],
|
427
|
+
# Use adjusted date
|
428
|
+
["stop_datetime", ">=", f"{adjusted_start_date} 23:00:00"],
|
429
|
+
]
|
430
|
+
if employee_id:
|
431
|
+
domain.append(
|
432
|
+
["employee_id", "=", employee_id],
|
433
|
+
)
|
434
|
+
|
435
|
+
try:
|
436
|
+
holidays = odoo.search_read(
|
437
|
+
model_name="hr.leave.report.calendar",
|
438
|
+
domain=domain,
|
439
|
+
)
|
440
|
+
parsed_holidays = [Holiday(**holiday) for holiday in holidays]
|
441
|
+
return SearchHolidaysResponse(success=True, result=parsed_holidays)
|
442
|
+
|
443
|
+
except Exception as e:
|
444
|
+
return SearchHolidaysResponse(success=False, error=str(e))
|