biszx-odoo-mcp 1.1.0__py3-none-any.whl → 1.1.2__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,432 @@
1
+ """
2
+ MCP Tools for Odoo integration
3
+
4
+ This module contains all the MCP tool functions for interacting with Odoo.
5
+ """
6
+
7
+ from typing import Any, cast
8
+
9
+ from biszx_odoo_mcp.exceptions import OdooMCPError, ToolError
10
+ from biszx_odoo_mcp.server.context import AppContext
11
+ from biszx_odoo_mcp.server.response import Response
12
+
13
+
14
+ async def search_models(mcp: Any, query: str) -> dict[str, Any]:
15
+ """
16
+ Get a list of all available models in the Odoo system.
17
+ Args:
18
+ query: Search term to find models (searches in model name and display name)
19
+ Returns:
20
+ JSON string with matching models
21
+ """
22
+ # Access lifespan context to get the Odoo client
23
+ ctx = mcp.get_context()
24
+ app_context = cast(AppContext, ctx.request_context.lifespan_context)
25
+
26
+ try:
27
+ data = app_context.odoo.search_models(query)
28
+ return Response(data=data).to_dict()
29
+ except OdooMCPError as e:
30
+ return Response(error=e.to_dict()).to_dict()
31
+ except Exception as e:
32
+ tool_error = ToolError(
33
+ f"Unexpected error getting models: {str(e)}",
34
+ tool_name="search_models",
35
+ original_error=e,
36
+ )
37
+ return Response(error=tool_error.to_dict()).to_dict()
38
+
39
+
40
+ async def get_model_info(mcp: Any, model_name: str) -> dict[str, Any]:
41
+ """
42
+ Get information about a specific Odoo model.
43
+ Args:
44
+ model_name: Name of the model (e.g., 'res.partner')
45
+ Returns:
46
+ Dictionary with model information
47
+ """
48
+ # Access lifespan context to get the Odoo client
49
+ ctx = mcp.get_context()
50
+ app_context = cast(AppContext, ctx.request_context.lifespan_context)
51
+
52
+ try:
53
+ data = app_context.odoo.get_model_info(model_name)
54
+ return Response(data=data).to_dict()
55
+ except OdooMCPError as e:
56
+ return Response(error=e.to_dict()).to_dict()
57
+ except Exception as e:
58
+ tool_error = ToolError(
59
+ f"Unexpected error getting model info: {str(e)}",
60
+ tool_name="get_model_info",
61
+ details={"model_name": model_name},
62
+ original_error=e,
63
+ )
64
+ return Response(error=tool_error.to_dict()).to_dict()
65
+
66
+
67
+ async def get_model_fields(
68
+ mcp: Any, model_name: str, query_field: str
69
+ ) -> dict[str, Any]:
70
+ """
71
+ Get field definitions for a specific Odoo model.
72
+ Args:
73
+ model_name: Name of the model (e.g., 'res.partner')
74
+ query_field: Search term to find fields (searches in field name and string)
75
+ Returns:
76
+ Dictionary with field definitions
77
+ """
78
+ # Access lifespan context to get the Odoo client
79
+ ctx = mcp.get_context()
80
+ app_context = cast(AppContext, ctx.request_context.lifespan_context)
81
+
82
+ try:
83
+ data = app_context.odoo.get_model_fields(model_name, query_field)
84
+ return Response(data=data).to_dict()
85
+ except OdooMCPError as e:
86
+ return Response(error=e.to_dict()).to_dict()
87
+ except Exception as e:
88
+ tool_error = ToolError(
89
+ f"Unexpected error getting model fields: {str(e)}",
90
+ tool_name="get_model_fields",
91
+ details={"model_name": model_name},
92
+ original_error=e,
93
+ )
94
+ return Response(error=tool_error.to_dict()).to_dict()
95
+
96
+
97
+ async def search_records(
98
+ mcp: Any,
99
+ model_name: str,
100
+ domain: list[Any],
101
+ fields: list[str] | None = None,
102
+ limit: int | None = None,
103
+ offset: int | None = None,
104
+ order: str | None = None,
105
+ ) -> dict[str, Any]:
106
+ """
107
+ Search for records in an Odoo model.
108
+ Args:
109
+ model_name: Name of the model e.g., 'res.partner'
110
+ domain: Search domain as list of tuples e.g., [['is_company', '=', true]]
111
+ fields: List of field names to return, None for all fields (default: None)
112
+ limit: Maximum number of records to return (default: None)
113
+ offset: Number of records to skip (default: None)
114
+ order: Sorting criteria e.g., 'name ASC, id DESC' (default: None)
115
+ Returns:
116
+ Dictionary with search results
117
+ """
118
+ # Access lifespan context to get the Odoo client
119
+ ctx = mcp.get_context()
120
+ app_context = cast(AppContext, ctx.request_context.lifespan_context)
121
+
122
+ try:
123
+ data = app_context.odoo.search_read(
124
+ model_name, domain, fields=fields, limit=limit, offset=offset, order=order
125
+ )
126
+ return Response(data=data).to_dict()
127
+ except OdooMCPError as e:
128
+ return Response(error=e.to_dict()).to_dict()
129
+
130
+
131
+ async def read_records(
132
+ mcp: Any,
133
+ model_name: str,
134
+ ids: list[int],
135
+ fields: list[str] | None = None,
136
+ ) -> dict[str, Any]:
137
+ """
138
+ Read specific records by their IDs.
139
+ Args:
140
+ model_name: Name of the model e.g., 'res.partner'
141
+ ids: List of record IDs to read
142
+ fields: List of field names to return, None for all fields (default: None)
143
+ Returns:
144
+ Dictionary with record data
145
+ """
146
+ # Access lifespan context to get the Odoo client
147
+ ctx = mcp.get_context()
148
+ app_context = cast(AppContext, ctx.request_context.lifespan_context)
149
+
150
+ try:
151
+ data = app_context.odoo.read_records(model_name, ids, fields=fields)
152
+ return Response(data=data).to_dict()
153
+ except OdooMCPError as e:
154
+ return Response(error=e.to_dict()).to_dict()
155
+
156
+
157
+ async def create_record(
158
+ mcp: Any,
159
+ model_name: str,
160
+ values: dict[str, Any],
161
+ ) -> dict[str, Any]:
162
+ """
163
+ Create a new record in an Odoo model.
164
+ Args:
165
+ model_name: Name of the model e.g., 'res.partner'
166
+ values: Dictionary with field values for the new record
167
+ Returns:
168
+ Dictionary with the created record ID
169
+ """
170
+ # Access lifespan context to get the Odoo client
171
+ ctx = mcp.get_context()
172
+ app_context = cast(AppContext, ctx.request_context.lifespan_context)
173
+
174
+ try:
175
+ record_id = app_context.odoo.create_records(model_name, [values])
176
+ return Response(data={"id": record_id}).to_dict()
177
+ except OdooMCPError as e:
178
+ return Response(error=e.to_dict()).to_dict()
179
+
180
+
181
+ async def create_records(
182
+ mcp: Any,
183
+ model_name: str,
184
+ values_list: list[dict[str, Any]],
185
+ ) -> dict[str, Any]:
186
+ """
187
+ Create multiple records in an Odoo model.
188
+ Args:
189
+ model_name: Name of the model e.g., 'res.partner'
190
+ values_list: List of dictionaries with field values for the new records
191
+ Returns:
192
+ Dictionary with the created record IDs
193
+ """
194
+ # Access lifespan context to get the Odoo client
195
+ ctx = mcp.get_context()
196
+ app_context = cast(AppContext, ctx.request_context.lifespan_context)
197
+
198
+ try:
199
+ record_ids = app_context.odoo.create_records(model_name, values_list)
200
+ return Response(data={"ids": record_ids}).to_dict()
201
+ except OdooMCPError as e:
202
+ return Response(error=e.to_dict()).to_dict()
203
+
204
+
205
+ async def write_record(
206
+ mcp: Any,
207
+ model_name: str,
208
+ record_id: int,
209
+ values: dict[str, Any],
210
+ ) -> dict[str, Any]:
211
+ """
212
+ Update a single record in an Odoo model.
213
+ Args:
214
+ model_name: Name of the model e.g., 'res.partner'
215
+ record_id: ID of the record to update
216
+ values: Dictionary with field values to update
217
+ Returns:
218
+ Dictionary with operation result
219
+ """
220
+ # Access lifespan context to get the Odoo client
221
+ ctx = mcp.get_context()
222
+ app_context = cast(AppContext, ctx.request_context.lifespan_context)
223
+
224
+ try:
225
+ result = app_context.odoo.write_records(model_name, [record_id], values)
226
+ return Response(data={"success": result}).to_dict()
227
+ except OdooMCPError as e:
228
+ return Response(error=e.to_dict()).to_dict()
229
+
230
+
231
+ async def write_records(
232
+ mcp: Any,
233
+ model_name: str,
234
+ record_ids: list[int],
235
+ values: dict[str, Any],
236
+ ) -> dict[str, Any]:
237
+ """
238
+ Update multiple records in an Odoo model.
239
+ Args:
240
+ model_name: Name of the model e.g., 'res.partner'
241
+ record_ids: List of record IDs to update
242
+ values: Dictionary with field values to update
243
+ Returns:
244
+ Dictionary with operation result
245
+ """
246
+ # Access lifespan context to get the Odoo client
247
+ ctx = mcp.get_context()
248
+ app_context = cast(AppContext, ctx.request_context.lifespan_context)
249
+
250
+ try:
251
+ result = app_context.odoo.write_records(model_name, record_ids, values)
252
+ return Response(data={"success": result}).to_dict()
253
+ except OdooMCPError as e:
254
+ return Response(error=e.to_dict()).to_dict()
255
+
256
+
257
+ async def unlink_record(
258
+ mcp: Any,
259
+ model_name: str,
260
+ record_id: int,
261
+ ) -> dict[str, Any]:
262
+ """
263
+ Delete a single record from an Odoo model.
264
+ Args:
265
+ model_name: Name of the model e.g., 'res.partner'
266
+ record_id: ID of the record to delete
267
+ Returns:
268
+ Dictionary with operation result
269
+ """
270
+ # Access lifespan context to get the Odoo client
271
+ ctx = mcp.get_context()
272
+ app_context = cast(AppContext, ctx.request_context.lifespan_context)
273
+
274
+ try:
275
+ result = app_context.odoo.unlink_records(model_name, [record_id])
276
+ return Response(data={"success": result}).to_dict()
277
+ except OdooMCPError as e:
278
+ return Response(error=e.to_dict()).to_dict()
279
+
280
+
281
+ async def unlink_records(
282
+ mcp: Any,
283
+ model_name: str,
284
+ record_ids: list[int],
285
+ ) -> dict[str, Any]:
286
+ """
287
+ Delete multiple records from an Odoo model.
288
+ Args:
289
+ model_name: Name of the model e.g., 'res.partner'
290
+ record_ids: List of record IDs to delete
291
+ Returns:
292
+ Dictionary with operation result
293
+ """
294
+ # Access lifespan context to get the Odoo client
295
+ ctx = mcp.get_context()
296
+ app_context = cast(AppContext, ctx.request_context.lifespan_context)
297
+
298
+ try:
299
+ result = app_context.odoo.unlink_records(model_name, record_ids)
300
+ return Response(data={"success": result}).to_dict()
301
+ except OdooMCPError as e:
302
+ return Response(error=e.to_dict()).to_dict()
303
+
304
+
305
+ async def search_count(
306
+ mcp: Any,
307
+ model_name: str,
308
+ domain: list[Any],
309
+ ) -> dict[str, Any]:
310
+ """
311
+ Count records that match a search domain.
312
+ Args:
313
+ model_name: Name of the model e.g., 'res.partner'
314
+ domain: Search domain as list of tuples e.g., [['is_company', '=', True]]
315
+ Returns:
316
+ Dictionary with the count of matching records
317
+ """
318
+ # Access lifespan context to get the Odoo client
319
+ ctx = mcp.get_context()
320
+ app_context = cast(AppContext, ctx.request_context.lifespan_context)
321
+
322
+ try:
323
+ count = app_context.odoo.search_count(model_name, domain)
324
+ return Response(data={"count": count}).to_dict()
325
+ except OdooMCPError as e:
326
+ return Response(error=e.to_dict()).to_dict()
327
+
328
+
329
+ async def search_ids(
330
+ mcp: Any,
331
+ model_name: str,
332
+ domain: list[Any],
333
+ offset: int | None = None,
334
+ limit: int | None = None,
335
+ order: str | None = None,
336
+ ) -> dict[str, Any]:
337
+ """
338
+ Search for record IDs that match a domain.
339
+ Args:
340
+ model_name: Name of the model e.g., 'res.partner'
341
+ domain: Search domain as list of tuples e.g., [['is_company', '=', True]]
342
+ offset: Number of records to skip (default: None)
343
+ limit: Maximum number of records to return (default: None)
344
+ order: Sorting criteria e.g., 'name ASC, id DESC' (default: None)
345
+ Returns:
346
+ Dictionary with list of matching record IDs
347
+ """
348
+ # Access lifespan context to get the Odoo client
349
+ ctx = mcp.get_context()
350
+ app_context = cast(AppContext, ctx.request_context.lifespan_context)
351
+
352
+ try:
353
+ ids = app_context.odoo.search_ids(
354
+ model_name, domain, offset=offset, limit=limit, order=order
355
+ )
356
+ return Response(data={"ids": ids}).to_dict()
357
+ except OdooMCPError as e:
358
+ return Response(error=e.to_dict()).to_dict()
359
+
360
+
361
+ async def call_method(
362
+ mcp: Any,
363
+ model_name: str,
364
+ method_name: str,
365
+ args: list[Any] | None = None,
366
+ kwargs: dict[str, Any] | None = None,
367
+ ) -> dict[str, Any]:
368
+ """
369
+ Call a custom method on an Odoo model.
370
+ Args:
371
+ model_name: Name of the model e.g., 'res.partner'
372
+ method_name: Name of the method to call
373
+ args: Positional arguments to pass to the method (default: None)
374
+ kwargs: Keyword arguments to pass to the method (default: None)
375
+ Returns:
376
+ Dictionary with method result
377
+ """
378
+ # Access lifespan context to get the Odoo client
379
+ ctx = mcp.get_context()
380
+ app_context = cast(AppContext, ctx.request_context.lifespan_context)
381
+
382
+ if args is None:
383
+ args = []
384
+ if kwargs is None:
385
+ kwargs = {}
386
+
387
+ try:
388
+ result = app_context.odoo.call_method(model_name, method_name, args, kwargs)
389
+ return Response(data=result).to_dict()
390
+ except OdooMCPError as e:
391
+ return Response(error=e.to_dict()).to_dict()
392
+
393
+
394
+ async def search_and_update(
395
+ mcp: Any,
396
+ model_name: str,
397
+ domain: list[Any],
398
+ values: dict[str, Any],
399
+ ) -> dict[str, Any]:
400
+ """
401
+ Search for records and update them in one operation.
402
+ Args:
403
+ model_name: Name of the model e.g., 'res.partner'
404
+ domain: Search domain to find records to update
405
+ values: Dictionary with field values to update
406
+ Returns:
407
+ Dictionary with operation results including affected record count
408
+ """
409
+ # Access lifespan context to get the Odoo client
410
+ ctx = mcp.get_context()
411
+ app_context = cast(AppContext, ctx.request_context.lifespan_context)
412
+
413
+ try:
414
+ # First search for IDs
415
+ record_ids = app_context.odoo.search_ids(model_name, domain)
416
+ if not record_ids:
417
+ return Response(
418
+ data={"affected_records": 0, "message": "No records found"}
419
+ ).to_dict()
420
+
421
+ # Then update the found records
422
+ result = app_context.odoo.write_records(model_name, record_ids, values)
423
+ return Response(
424
+ data={
425
+ "affected_records": len(record_ids),
426
+ "record_ids": record_ids,
427
+ "updated": result,
428
+ "update_result": result, # For backward compatibility
429
+ }
430
+ ).to_dict()
431
+ except OdooMCPError as e:
432
+ return Response(error=e.to_dict()).to_dict()
File without changes
@@ -0,0 +1,98 @@
1
+ """
2
+ Odoo Configuration for MCP server integration
3
+ """
4
+
5
+ import os
6
+ import re
7
+ from typing import Any
8
+
9
+ from loguru import logger
10
+
11
+
12
+ class Config:
13
+ """
14
+ Odoo client configuration
15
+ """
16
+
17
+ url: str
18
+ db: str
19
+ username: str
20
+ password: str
21
+ timeout: int
22
+ verify_ssl: bool
23
+
24
+ def __init__(self) -> None:
25
+ """
26
+ Initialize the Config object and load configuration
27
+ """
28
+ self.__dict__.update(self.load_config())
29
+ self.url = self._prepare_url(self.url)
30
+
31
+ logger.info("🔧 Odoo client configuration:")
32
+ logger.info(f" URL: {self.url}")
33
+ logger.info(f" Database: {self.db}")
34
+ logger.info(f" Username: {self.username}")
35
+ logger.info(f" Timeout: {self.timeout}s")
36
+ logger.info(f" Verify SSL: {self.verify_ssl}")
37
+
38
+ def load_config(self) -> dict[str, Any]:
39
+ """
40
+ Load Odoo configuration from environment variables or config file
41
+
42
+ Raises:
43
+ OSError: If required environment variables are missing
44
+ Returns:
45
+ dict: Configuration parameters for Odoo client
46
+ """
47
+
48
+ self._validate_config()
49
+ return {
50
+ "url": os.environ["ODOO_URL"],
51
+ "db": os.environ["ODOO_DB"],
52
+ "username": os.environ["ODOO_USERNAME"],
53
+ "password": os.environ["ODOO_PASSWORD"],
54
+ "timeout": int(os.environ.get("ODOO_TIMEOUT", "30")),
55
+ "verify_ssl": os.environ.get("ODOO_VERIFY_SSL", "1").lower()
56
+ in ("1", "true", "yes"),
57
+ }
58
+
59
+ def _validate_config(self) -> None:
60
+ """
61
+ Validate the loaded configuration parameters
62
+ Raises:
63
+ ValueError: If any required parameter is missing or invalid
64
+ """
65
+ required_env = {"ODOO_URL", "ODOO_DB", "ODOO_USERNAME", "ODOO_PASSWORD"}
66
+ for var in required_env:
67
+ if var not in os.environ:
68
+ raise OSError(f"Missing required environment variable: {var}")
69
+ if "ODOO_TIMEOUT" in os.environ:
70
+ try:
71
+ int(os.environ["ODOO_TIMEOUT"])
72
+ except ValueError:
73
+ raise ValueError("ODOO_TIMEOUT must be an integer") from None
74
+ if "ODOO_VERIFY_SSL" in os.environ and os.environ[
75
+ "ODOO_VERIFY_SSL"
76
+ ].lower() not in (
77
+ "1",
78
+ "true",
79
+ "yes",
80
+ "0",
81
+ "false",
82
+ "no",
83
+ ):
84
+ raise ValueError("ODOO_VERIFY_SSL must be 1, true, yes, 0, false, or no")
85
+
86
+ def _prepare_url(self, url: str) -> str:
87
+ """
88
+ Prepare the URL by ensuring it has a protocol and no trailing slash
89
+
90
+ Args:
91
+ url: The URL to prepare
92
+
93
+ Returns:
94
+ str: The prepared URL
95
+ """
96
+ if not re.match(r"^https?://", url):
97
+ url = f"http://{url}"
98
+ return url.rstrip("/")