Skip to content

Commit 3c42147

Browse files
committed
feat: add dynamic field importance scoring to smart fields
Fixes #8
1 parent 57e9e3d commit 3c42147

File tree

7 files changed

+246
-102
lines changed

7 files changed

+246
-102
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212

1313
### Changed
1414
- **Minimal Response Fields**: Reduced `create_record` and `update_record` tool responses to return only essential fields (id, name, display_name) to minimize LLM context usage
15+
- **Smart Field Optimization**: Implemented dynamic field importance scoring to reduce smart default fields to most essential across all models, with configurable limit via `ODOO_MCP_MAX_SMART_FIELDS`
1516

1617
## [0.2.1] - 2025-06-28
1718

mcp_server_odoo/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ class OdooConfig:
2929
log_level: str = "INFO"
3030
default_limit: int = 10
3131
max_limit: int = 100
32+
max_smart_fields: int = 15
3233

3334
# MCP transport configuration
3435
transport: Literal["stdio", "streamable-http"] = "stdio"
@@ -161,6 +162,7 @@ def get_int_env(key: str, default: int) -> int:
161162
log_level=os.getenv("ODOO_MCP_LOG_LEVEL", "INFO").strip(),
162163
default_limit=get_int_env("ODOO_MCP_DEFAULT_LIMIT", 10),
163164
max_limit=get_int_env("ODOO_MCP_MAX_LIMIT", 100),
165+
max_smart_fields=get_int_env("ODOO_MCP_MAX_SMART_FIELDS", 15),
164166
transport=os.getenv("ODOO_MCP_TRANSPORT", "stdio").strip(),
165167
host=os.getenv("ODOO_MCP_HOST", "localhost").strip(),
166168
port=get_int_env("ODOO_MCP_PORT", 8000),

mcp_server_odoo/tools.py

Lines changed: 140 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -216,8 +216,114 @@ def _should_include_field_by_default(self, field_name: str, field_info: Dict[str
216216

217217
return False
218218

219+
def _score_field_importance(self, field_name: str, field_info: Dict[str, Any]) -> int:
220+
"""Score field importance for smart default selection.
221+
222+
Args:
223+
field_name: Name of the field
224+
field_info: Field metadata from fields_get()
225+
226+
Returns:
227+
Importance score (higher = more important)
228+
"""
229+
# Tier 1: Essential fields (always included)
230+
if field_name in {"id", "name", "display_name", "active"}:
231+
return 1000
232+
233+
# Exclude system/technical fields by prefix
234+
exclude_prefixes = ("_", "message_", "activity_", "website_message_")
235+
if field_name.startswith(exclude_prefixes):
236+
return 0
237+
238+
# Exclude specific technical fields
239+
exclude_fields = {
240+
"write_date",
241+
"create_date",
242+
"write_uid",
243+
"create_uid",
244+
"__last_update",
245+
"access_token",
246+
"access_warning",
247+
"access_url",
248+
}
249+
if field_name in exclude_fields:
250+
return 0
251+
252+
score = 0
253+
254+
# Tier 2: Required fields are very important
255+
if field_info.get("required"):
256+
score += 500
257+
258+
# Tier 3: Field type importance
259+
field_type = field_info.get("type", "")
260+
type_scores = {
261+
"char": 200,
262+
"boolean": 180,
263+
"selection": 170,
264+
"integer": 160,
265+
"float": 160,
266+
"monetary": 140,
267+
"date": 150,
268+
"datetime": 150,
269+
"many2one": 120, # Relations useful but not primary
270+
"text": 80,
271+
"one2many": 40,
272+
"many2many": 40, # Heavy relations
273+
"binary": 10,
274+
"html": 10,
275+
"image": 10, # Heavy content
276+
}
277+
score += type_scores.get(field_type, 50)
278+
279+
# Tier 4: Storage and searchability bonuses
280+
if field_info.get("store", True):
281+
score += 80
282+
if field_info.get("searchable", True):
283+
score += 40
284+
285+
# Tier 5: Business-relevant field patterns (bonus)
286+
business_patterns = [
287+
"state",
288+
"status",
289+
"stage",
290+
"priority",
291+
"company",
292+
"currency",
293+
"amount",
294+
"total",
295+
"date",
296+
"user",
297+
"partner",
298+
"email",
299+
"phone",
300+
"address",
301+
"street",
302+
"city",
303+
"country",
304+
"code",
305+
"ref",
306+
"number",
307+
]
308+
if any(pattern in field_name.lower() for pattern in business_patterns):
309+
score += 60
310+
311+
# Exclude expensive computed fields (non-stored)
312+
if field_info.get("compute") and not field_info.get("store", True):
313+
score = min(score, 30) # Cap computed fields at low score
314+
315+
# Exclude large field types completely
316+
if field_type in ("binary", "image", "html"):
317+
return 0
318+
319+
# Exclude one2many and many2many fields (can be large)
320+
if field_type in ("one2many", "many2many"):
321+
return 0
322+
323+
return max(score, 0)
324+
219325
def _get_smart_default_fields(self, model: str) -> Optional[List[str]]:
220-
"""Get smart default fields for a model.
326+
"""Get smart default fields for a model using field importance scoring.
221327
222328
Args:
223329
model: The Odoo model name
@@ -229,26 +335,41 @@ def _get_smart_default_fields(self, model: str) -> Optional[List[str]]:
229335
# Get all field definitions
230336
fields_info = self.connection.fields_get(model)
231337

232-
# Apply smart filtering
233-
default_fields = [
234-
field_name
235-
for field_name, field_info in fields_info.items()
236-
if self._should_include_field_by_default(field_name, field_info)
237-
]
238-
239-
# Ensure we have at least some fields
240-
if not default_fields:
241-
default_fields = ["id", "name", "display_name"]
242-
243-
# Sort fields for consistent output
244-
# Priority order: id, name, display_name, then alphabetical
245-
priority_fields = ["id", "name", "display_name", "active"]
246-
other_fields = sorted(f for f in default_fields if f not in priority_fields)
247-
248-
final_fields = [f for f in priority_fields if f in default_fields] + other_fields
338+
# Score all fields by importance
339+
field_scores = []
340+
for field_name, field_info in fields_info.items():
341+
score = self._score_field_importance(field_name, field_info)
342+
if score > 0: # Only include fields with positive scores
343+
field_scores.append((field_name, score))
344+
345+
# Sort by score (highest first)
346+
field_scores.sort(key=lambda x: x[1], reverse=True)
347+
348+
# Select top N fields based on configuration
349+
max_fields = self.config.max_smart_fields
350+
selected_fields = [field_name for field_name, _ in field_scores[:max_fields]]
351+
352+
# Ensure essential fields are always included
353+
essential_fields = ["id", "name", "display_name", "active"]
354+
for field in essential_fields:
355+
if field in fields_info and field not in selected_fields:
356+
selected_fields.append(field)
357+
358+
# Remove duplicates while preserving order
359+
final_fields = []
360+
seen = set()
361+
for field in selected_fields:
362+
if field not in seen:
363+
final_fields.append(field)
364+
seen.add(field)
365+
366+
# Ensure we have at least essential fields
367+
if not final_fields:
368+
final_fields = [f for f in essential_fields if f in fields_info]
249369

250370
logger.debug(
251-
f"Smart default fields for {model}: {len(final_fields)} of {len(fields_info)} fields"
371+
f"Smart default fields for {model}: {len(final_fields)} of {len(fields_info)} fields "
372+
f"(max configured: {max_fields})"
252373
)
253374
return final_fields
254375

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "mcp-server-odoo"
7-
version = "0.2.1"
7+
version = "0.2.2"
88
description = "A Model Context Protocol server for Odoo ERP systems"
99
readme = "README.md"
1010
requires-python = ">=3.10"

tests/test_search_smart_defaults.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ def tool_handler(self):
1919
config = Mock()
2020
config.default_limit = 10
2121
config.max_limit = 100
22+
config.max_smart_fields = 15
2223

2324
return OdooToolHandler(app, connection, access_controller, config)
2425

0 commit comments

Comments
 (0)