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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (78.1.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ odoo-mcp = odoo_mcp.__main__:main
@@ -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
@@ -0,0 +1,7 @@
1
+ """
2
+ Odoo MCP Server - MCP Server for Odoo Integration
3
+ """
4
+
5
+ from .server import mcp
6
+
7
+ __all__ = ["mcp"]
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())
@@ -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))