biszx-odoo-mcp 1.2.0__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.

Potentially problematic release.


This version of biszx-odoo-mcp might be problematic. Click here for more details.

@@ -0,0 +1,36 @@
1
+ """
2
+ Odoo MCP Server Response
3
+ """
4
+
5
+ import json
6
+ from typing import Any, Optional
7
+
8
+
9
+ class Response:
10
+ """
11
+ Standard response wrapper for OdooClient methods.
12
+ """
13
+
14
+ def __init__(
15
+ self,
16
+ data: Optional[Any] = None,
17
+ error: Optional[dict[str, Any]] = None,
18
+ ) -> None:
19
+ self.data = data
20
+ self.error = error
21
+ self.success = error is None
22
+
23
+ def to_dict(self) -> dict[str, Any]:
24
+ """
25
+ Return the response as a dictionary with either 'data' or 'error' key.
26
+ """
27
+ if self.error is not None:
28
+ return {"success": self.success, "error": self.error}
29
+ return {"success": self.success, "data": self.data}
30
+
31
+ def to_json_string(self, indent: int = 2) -> str:
32
+ """
33
+ Return the response as a JSON string.
34
+ """
35
+
36
+ return json.dumps(self.to_dict(), indent=indent)
@@ -0,0 +1,431 @@
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: null)
112
+ limit: Maximum number of records to return (default: null)
113
+ offset: Number of records to skip (default: null)
114
+ order: Sorting criteria e.g., 'name ASC, id DESC' (default: null)
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 search_count(
132
+ mcp: Any,
133
+ model_name: str,
134
+ domain: list[Any],
135
+ ) -> dict[str, Any]:
136
+ """
137
+ Count records that match a search domain.
138
+ Args:
139
+ model_name: Name of the model e.g., 'res.partner'
140
+ domain: Search domain as list of tuples e.g., [['is_company', '=', True]]
141
+ Returns:
142
+ Dictionary with the count of matching records
143
+ """
144
+ # Access lifespan context to get the Odoo client
145
+ ctx = mcp.get_context()
146
+ app_context = cast(AppContext, ctx.request_context.lifespan_context)
147
+
148
+ try:
149
+ count = app_context.odoo.search_count(model_name, domain)
150
+ return Response(data={"count": count}).to_dict()
151
+ except OdooMCPError as e:
152
+ return Response(error=e.to_dict()).to_dict()
153
+
154
+
155
+ async def search_ids(
156
+ mcp: Any,
157
+ model_name: str,
158
+ domain: list[Any],
159
+ offset: int | None = None,
160
+ limit: int | None = None,
161
+ order: str | None = None,
162
+ ) -> dict[str, Any]:
163
+ """
164
+ Search for record IDs that match a domain.
165
+ Args:
166
+ model_name: Name of the model e.g., 'res.partner'
167
+ domain: Search domain as list of tuples e.g., [['is_company', '=', True]]
168
+ offset: Number of records to skip (default: null)
169
+ limit: Maximum number of records to return (default: null)
170
+ order: Sorting criteria e.g., 'name ASC, id DESC' (default: null)
171
+ Returns:
172
+ Dictionary with list of matching record IDs
173
+ """
174
+ # Access lifespan context to get the Odoo client
175
+ ctx = mcp.get_context()
176
+ app_context = cast(AppContext, ctx.request_context.lifespan_context)
177
+
178
+ try:
179
+ ids = app_context.odoo.search_ids(
180
+ model_name, domain, offset=offset, limit=limit, order=order
181
+ )
182
+ return Response(data={"ids": ids}).to_dict()
183
+ except OdooMCPError as e:
184
+ return Response(error=e.to_dict()).to_dict()
185
+
186
+
187
+ async def read_records(
188
+ mcp: Any,
189
+ model_name: str,
190
+ ids: list[int],
191
+ fields: list[str] | None = None,
192
+ ) -> dict[str, Any]:
193
+ """
194
+ Read specific records by their IDs.
195
+ Args:
196
+ model_name: Name of the model e.g., 'res.partner'
197
+ ids: List of record IDs to read
198
+ fields: List of field names to return, None for all fields (default: null)
199
+ Returns:
200
+ Dictionary with record data
201
+ """
202
+ # Access lifespan context to get the Odoo client
203
+ ctx = mcp.get_context()
204
+ app_context = cast(AppContext, ctx.request_context.lifespan_context)
205
+
206
+ try:
207
+ data = app_context.odoo.read_records(model_name, ids, fields=fields)
208
+ return Response(data=data).to_dict()
209
+ except OdooMCPError as e:
210
+ return Response(error=e.to_dict()).to_dict()
211
+
212
+
213
+ async def create_records(
214
+ mcp: Any,
215
+ model_name: str,
216
+ values_list: list[dict[str, Any]],
217
+ ) -> dict[str, Any]:
218
+ """
219
+ Create multiple records in an Odoo model.
220
+ Args:
221
+ model_name: Name of the model e.g., 'res.partner'
222
+ values_list: List of dictionaries with field values for the new records
223
+ Returns:
224
+ Dictionary with the created record IDs
225
+ """
226
+ # Access lifespan context to get the Odoo client
227
+ ctx = mcp.get_context()
228
+ app_context = cast(AppContext, ctx.request_context.lifespan_context)
229
+
230
+ try:
231
+ record_ids = app_context.odoo.create_records(model_name, values_list)
232
+ return Response(data={"ids": record_ids}).to_dict()
233
+ except OdooMCPError as e:
234
+ return Response(error=e.to_dict()).to_dict()
235
+
236
+
237
+ async def write_records(
238
+ mcp: Any,
239
+ model_name: str,
240
+ record_ids: list[int],
241
+ values: dict[str, Any],
242
+ ) -> dict[str, Any]:
243
+ """
244
+ Update multiple records in an Odoo model.
245
+ Args:
246
+ model_name: Name of the model e.g., 'res.partner'
247
+ record_ids: List of record IDs to update
248
+ values: Dictionary with field values to update
249
+ Returns:
250
+ Dictionary with operation result
251
+ """
252
+ # Access lifespan context to get the Odoo client
253
+ ctx = mcp.get_context()
254
+ app_context = cast(AppContext, ctx.request_context.lifespan_context)
255
+
256
+ try:
257
+ result = app_context.odoo.write_records(model_name, record_ids, values)
258
+ return Response(data={"success": result}).to_dict()
259
+ except OdooMCPError as e:
260
+ return Response(error=e.to_dict()).to_dict()
261
+
262
+
263
+ async def search_and_write(
264
+ mcp: Any,
265
+ model_name: str,
266
+ domain: list[Any],
267
+ values: dict[str, Any],
268
+ ) -> dict[str, Any]:
269
+ """
270
+ Search for records and update them in one operation.
271
+ Args:
272
+ model_name: Name of the model e.g., 'res.partner'
273
+ domain: Search domain to find records to update
274
+ values: Dictionary with field values to update
275
+ Returns:
276
+ Dictionary with operation results including affected record count
277
+ """
278
+ # Access lifespan context to get the Odoo client
279
+ ctx = mcp.get_context()
280
+ app_context = cast(AppContext, ctx.request_context.lifespan_context)
281
+
282
+ try:
283
+ # First search for IDs
284
+ record_ids = app_context.odoo.search_ids(model_name, domain)
285
+ if not record_ids:
286
+ return Response(
287
+ data={"affected_records": 0, "message": "No records found"}
288
+ ).to_dict()
289
+
290
+ # Then update the found records
291
+ app_context.odoo.write_records(model_name, record_ids, values)
292
+ return Response(
293
+ data={
294
+ "affected_records": len(record_ids),
295
+ "record_ids": record_ids,
296
+ }
297
+ ).to_dict()
298
+ except OdooMCPError as e:
299
+ return Response(error=e.to_dict()).to_dict()
300
+
301
+
302
+ async def unlink_records(
303
+ mcp: Any,
304
+ model_name: str,
305
+ record_ids: list[int],
306
+ ) -> dict[str, Any]:
307
+ """
308
+ Delete multiple records from an Odoo model.
309
+ Args:
310
+ model_name: Name of the model e.g., 'res.partner'
311
+ record_ids: List of record IDs to delete
312
+ Returns:
313
+ Dictionary with operation result
314
+ """
315
+ # Access lifespan context to get the Odoo client
316
+ ctx = mcp.get_context()
317
+ app_context = cast(AppContext, ctx.request_context.lifespan_context)
318
+
319
+ try:
320
+ app_context.odoo.unlink_records(model_name, record_ids)
321
+ return Response().to_dict()
322
+ except OdooMCPError as e:
323
+ return Response(error=e.to_dict()).to_dict()
324
+
325
+
326
+ async def search_and_unlink(
327
+ mcp: Any,
328
+ model_name: str,
329
+ domain: list[Any],
330
+ ) -> dict[str, Any]:
331
+ """
332
+ Search for records and delete them in one operation.
333
+ Args:
334
+ model_name: Name of the model e.g., 'res.partner'
335
+ domain: Search domain to find records to delete
336
+ Returns:
337
+ Dictionary with operation results including affected record count
338
+ """
339
+ # Access lifespan context to get the Odoo client
340
+ ctx = mcp.get_context()
341
+ app_context = cast(AppContext, ctx.request_context.lifespan_context)
342
+
343
+ try:
344
+ # First search for IDs
345
+ record_ids = app_context.odoo.search_ids(model_name, domain)
346
+ if not record_ids:
347
+ return Response(error={"error": "No records found"}).to_dict()
348
+
349
+ # Then delete the found records
350
+ app_context.odoo.unlink_records(model_name, record_ids)
351
+ return Response(
352
+ data={"affected_records": len(record_ids), "record_ids": record_ids}
353
+ ).to_dict()
354
+ except OdooMCPError as e:
355
+ return Response(error=e.to_dict()).to_dict()
356
+
357
+
358
+ async def call_method(
359
+ mcp: Any,
360
+ model_name: str,
361
+ method_name: str,
362
+ args: list[Any] | None = None,
363
+ kwargs: dict[str, Any] | None = None,
364
+ ) -> dict[str, Any]:
365
+ """
366
+ Call a custom method on an Odoo model.
367
+ Args:
368
+ model_name: Name of the model e.g., 'res.partner'
369
+ method_name: Name of the method to call
370
+ args: Positional arguments to pass to the method (default: null)
371
+ kwargs: Keyword arguments to pass to the method (default: null)
372
+ Returns:
373
+ Dictionary with method result
374
+ """
375
+ # Access lifespan context to get the Odoo client
376
+ ctx = mcp.get_context()
377
+ app_context = cast(AppContext, ctx.request_context.lifespan_context)
378
+
379
+ if args is None:
380
+ args = []
381
+ if kwargs is None:
382
+ kwargs = {}
383
+
384
+ try:
385
+ result = app_context.odoo.call_method(model_name, method_name, args, kwargs)
386
+ return Response(data=result).to_dict()
387
+ except OdooMCPError as e:
388
+ return Response(error=e.to_dict()).to_dict()
389
+
390
+
391
+ async def read_group(
392
+ mcp: Any,
393
+ model_name: str,
394
+ domain: list[Any],
395
+ fields: list[str],
396
+ groupby: list[str],
397
+ offset: int | None = None,
398
+ limit: int | None = None,
399
+ order: str | None = None,
400
+ ) -> dict[str, Any]:
401
+ """
402
+ Group records and perform aggregations on an Odoo model.
403
+ Args:
404
+ model_name: Name of the model e.g., 'res.partner'
405
+ domain: Search domain as list of tuples e.g., [['is_company', '=', True]]
406
+ fields: List of field names to include in results, can include
407
+ aggregation functions e.g., ['name', 'total_amount:sum']
408
+ groupby: List of field names to group by e.g., ['name']
409
+ offset: Number of groups to skip (default: null)
410
+ limit: Maximum number of groups to return (default: null)
411
+ order: Sorting criteria for groups e.g., 'field_name ASC' (default: null)
412
+ Returns:
413
+ Dictionary with grouped and aggregated data
414
+ """
415
+ # Access lifespan context to get the Odoo client
416
+ ctx = mcp.get_context()
417
+ app_context = cast(AppContext, ctx.request_context.lifespan_context)
418
+
419
+ try:
420
+ data = app_context.odoo.read_group(
421
+ model_name,
422
+ domain,
423
+ fields=fields,
424
+ groupby=groupby,
425
+ offset=offset,
426
+ limit=limit,
427
+ order=order,
428
+ )
429
+ return Response(data=data).to_dict()
430
+ except OdooMCPError as e:
431
+ 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("/")