Skip to main content
Glama

MCP Server for Odoo

formatters.py•17.6 kB
"""Data formatters for LLM-friendly output. This module provides formatters that convert Odoo data into hierarchical text format optimized for LLM consumption. """ import logging from datetime import date, datetime from typing import Any, Dict, List, Optional, Set from .uri_schema import build_record_uri, build_search_uri logger = logging.getLogger(__name__) class RecordFormatter: """Formats Odoo records for LLM consumption. This class converts complex Odoo data structures into hierarchical text format that is easy for LLMs to understand and process. """ # Field types that should be omitted by default OMIT_FIELDS = { "__last_update", "write_date", "create_date", "write_uid", "create_uid", "message_follower_ids", "message_ids", "message_main_attachment_id", } # Binary field types BINARY_FIELDS = {"binary", "image", "file"} def __init__(self, model: str, max_related_items: int = 5): """Initialize the formatter. Args: model: The Odoo model name max_related_items: Maximum number of related items to show inline """ self.model = model self.max_related_items = max_related_items self._recursion_stack: Set[str] = set() def format_record( self, record: Dict[str, Any], fields_metadata: Optional[Dict[str, Dict[str, Any]]] = None, indent_level: int = 0, ) -> str: """Format a single record into hierarchical text. Args: record: The record data dictionary fields_metadata: Optional field metadata from fields_get() indent_level: Current indentation level for nested structures Returns: Formatted text representation of the record """ lines = [] indent = " " * indent_level # Record header record_id = record.get("id", "Unknown") record_name = record.get("display_name") or record.get("name", f"Record {record_id}") lines.append(f"{indent}{'=' * 50}") lines.append(f"{indent}Record: {self.model}/{record_id}") lines.append(f"{indent}Name: {record_name}") lines.append(f"{indent}{'=' * 50}") # Group fields by category simple_fields = [] relation_fields = [] for field_name, field_value in record.items(): # Skip omitted fields if field_name in self.OMIT_FIELDS or field_name.startswith("_"): continue # Skip ID and name as they're in the header if field_name in ("id", "name", "display_name"): continue # Get field metadata if available field_meta = fields_metadata.get(field_name, {}) if fields_metadata else {} field_type = field_meta.get("type", "unknown") # Categorize fields if field_type in ("many2one", "one2many", "many2many"): relation_fields.append((field_name, field_value, field_meta)) else: simple_fields.append((field_name, field_value, field_meta)) # Format simple fields first if simple_fields: lines.append(f"{indent}Fields:") for field_name, field_value, field_meta in simple_fields: formatted_value = self._format_field_value( field_name, field_value, field_meta, indent_level + 1 ) lines.append(f"{indent} {field_name}: {formatted_value}") # Format relationship fields if relation_fields: lines.append(f"{indent}Relationships:") for field_name, field_value, field_meta in relation_fields: lines.extend( self._format_relation_field( field_name, field_value, field_meta, indent_level + 1 ) ) return "\n".join(lines) def format_list( self, records: List[Dict[str, Any]], fields_metadata: Optional[Dict[str, Dict[str, Any]]] = None, ) -> str: """Format a list of records into hierarchical text. Args: records: List of record dictionaries fields_metadata: Optional field metadata from fields_get() Returns: Formatted text representation of the record list """ if not records: return f"No {self.model} records found." lines = [f"{'=' * 60}", f"{self.model} Records ({len(records)} found)", f"{'=' * 60}", ""] for idx, record in enumerate(records, 1): lines.append(f"[{idx}] {self._get_record_summary(record)}") lines.append("") return "\n".join(lines) def _format_field_value( self, field_name: str, value: Any, field_meta: Dict[str, Any], indent_level: int ) -> str: """Format a field value based on its type. Args: field_name: The field name value: The field value field_meta: Field metadata indent_level: Current indentation level Returns: Formatted field value """ if value is None or value is False: return "Not set" field_type = field_meta.get("type", "unknown") # Text fields if field_type in ("char", "text", "html"): return str(value) # Numeric fields elif field_type in ("integer", "float", "monetary"): if field_type == "monetary": # Try to get currency information # TODO: Use currency_field to get proper currency formatting # currency_field = field_meta.get("currency_field", "currency_id") return f"{value:,.2f}" # Format with thousand separators elif field_type == "float": digits = field_meta.get("digits", (16, 2)) precision = digits[1] if isinstance(digits, tuple) else 2 return f"{value:,.{precision}f}" else: return f"{value:,}" # Integer with thousand separators # Date/time fields elif field_type in ("date", "datetime"): if isinstance(value, str): # Handle Odoo's datetime format (YYYYMMDDTHH:MM:SS) if ( field_type == "datetime" and len(value) == 17 and "T" in value and "-" not in value ): try: # Parse Odoo's compact datetime format dt = datetime.strptime(value, "%Y%m%dT%H:%M:%S") # Return proper ISO format with UTC timezone return dt.strftime("%Y-%m-%dT%H:%M:%S+00:00") except ValueError: pass # Handle standard datetime formats elif field_type == "datetime" and " " in value: try: # Parse standard Odoo datetime format dt = datetime.strptime(value, "%Y-%m-%d %H:%M:%S") return dt.strftime("%Y-%m-%dT%H:%M:%S+00:00") except ValueError: pass return value # Return as-is if parsing fails elif isinstance(value, (datetime, date)): if isinstance(value, datetime): # Ensure datetime includes timezone return value.strftime("%Y-%m-%dT%H:%M:%S+00:00") else: # Date only return value.isoformat() return str(value) # Boolean fields elif field_type == "boolean": return "Yes" if value else "No" # Selection fields elif field_type == "selection": # Try to get the human-readable selection value selection = field_meta.get("selection", []) for key, label in selection: if key == value: return f"{label} ({value})" return str(value) # Binary fields elif field_type in self.BINARY_FIELDS: return f"[Binary data - use {self.model}/{field_name} to retrieve]" # Unknown type else: return str(value) def _format_relation_field( self, field_name: str, value: Any, field_meta: Dict[str, Any], indent_level: int ) -> List[str]: """Format a relationship field. Args: field_name: The field name value: The field value field_meta: Field metadata indent_level: Current indentation level Returns: List of formatted lines """ lines = [] indent = " " * indent_level field_type = field_meta.get("type", "unknown") # Many2one fields if field_type == "many2one": if value and isinstance(value, (list, tuple)) and len(value) == 2: related_id, related_name = value related_model = field_meta.get("relation", "unknown") uri = build_record_uri(related_model, related_id) lines.append(f"{indent}{field_name}: {related_name} ({uri})") else: lines.append(f"{indent}{field_name}: Not set") # One2many and Many2many fields elif field_type in ("one2many", "many2many"): if value and isinstance(value, list): count = len(value) related_model = field_meta.get("relation", "unknown") # Build search URI for the related records if field_type == "one2many": # For one2many, search by the inverse field inverse_field = field_meta.get("relation_field", "parent_id") domain = [(inverse_field, "=", self._get_current_record_id())] else: # For many2many, we'd need the actual IDs domain = [("id", "in", value)] if value else [] search_uri = build_search_uri(related_model, domain=domain) lines.append(f"{indent}{field_name}: {count} record(s)") lines.append(f"{indent} → View all: {search_uri}") # Show first few items if count is small if count <= self.max_related_items and isinstance(value[0], dict): lines.append(f"{indent} Items:") for idx, item in enumerate(value[: self.max_related_items], 1): summary = self._get_record_summary(item) lines.append(f"{indent} [{idx}] {summary}") elif count > self.max_related_items: lines.append( f"{indent} (Showing count only - too many items to display inline)" ) else: lines.append(f"{indent}{field_name}: No records") return lines def _get_record_summary(self, record: Dict[str, Any]) -> str: """Get a one-line summary of a record. Args: record: The record dictionary Returns: One-line summary string """ # Try different fields for the summary summary_fields = ["display_name", "name", "complete_name", "partner_id", "title"] for field in summary_fields: if field in record and record[field]: value = record[field] if isinstance(value, (list, tuple)) and len(value) == 2: return f"{value[1]} (ID: {value[0]})" elif isinstance(value, str): return value # Fallback to ID return f"ID: {record.get('id', 'Unknown')}" def _get_current_record_id(self) -> Optional[int]: """Get the current record ID being formatted. This is a placeholder that should be set when formatting a specific record. Returns: Current record ID or None """ # This would be set by the formatting context return None class DatasetFormatter: """Formats datasets and search results for LLM consumption.""" def __init__(self, model: str): """Initialize the dataset formatter. Args: model: The Odoo model name """ self.model = model self.record_formatter = RecordFormatter(model) def format_search_results( self, records: List[Dict[str, Any]], domain: Optional[List] = None, fields: Optional[List[str]] = None, limit: Optional[int] = None, offset: Optional[int] = None, total_count: Optional[int] = None, fields_metadata: Optional[Dict[str, Any]] = None, next_uri: Optional[str] = None, prev_uri: Optional[str] = None, current_page: Optional[int] = None, total_pages: Optional[int] = None, ) -> str: """Format search results with context and pagination. Args: records: List of record dictionaries domain: Search domain used fields: Fields that were requested limit: Limit used in search offset: Offset used in search total_count: Total count of matching records fields_metadata: Optional field metadata for rich formatting next_uri: URI for next page of results prev_uri: URI for previous page of results current_page: Current page number total_pages: Total number of pages Returns: Formatted search results with pagination """ lines = [ f"{'=' * 60}", f"Search Results: {self.model}", f"{'=' * 60}", ] # Add search context if domain: lines.append(f"Search criteria: {self._format_domain(domain)}") # Add pagination info if total_count is not None: showing = len(records) if current_page and total_pages: lines.append(f"Page {current_page} of {total_pages}") if offset is not None: lines.append(f"Showing records {offset + 1}-{offset + showing} of {total_count}") else: lines.append(f"Showing {showing} of {total_count} records") else: lines.append(f"Found {len(records)} records") if fields: lines.append(f"Fields: {', '.join(fields)}") lines.append("") # Format each record if not records: lines.append("No records found matching the criteria.") else: for idx, record in enumerate(records, 1): if offset: idx = offset + idx lines.append(f"[{idx}] {self.record_formatter._get_record_summary(record)}") # Add selected field values if specific fields were requested if fields and len(fields) <= 5: # Only show inline for small field sets for field in fields: if field in record and field not in ("id", "name", "display_name"): value = record[field] formatted = self._format_simple_value(value) lines.append(f" {field}: {formatted}") lines.append("") # Add navigation links navigation = [] if prev_uri: navigation.append(f"← Previous page: {prev_uri}") if next_uri: navigation.append(f"→ Next page: {next_uri}") if navigation: lines.append("\nNavigation:") lines.extend(navigation) # Add summary statistics for large datasets if total_count and total_count > 100: lines.append("\nDataset Summary:") lines.append(f"Total records: {total_count:,}") if domain: lines.append("Use additional filters to refine results") return "\n".join(lines) def _format_domain(self, domain: List) -> str: """Format a search domain in human-readable form. Args: domain: Odoo search domain Returns: Human-readable domain description """ if not domain: return "All records" conditions = [] for condition in domain: if isinstance(condition, (list, tuple)) and len(condition) == 3: field, operator, value = condition conditions.append(f"{field} {operator} {value}") elif condition in ("&", "|", "!"): conditions.append(condition) return " ".join(conditions) if conditions else str(domain) def _format_simple_value(self, value: Any) -> str: """Format a simple value for inline display. Args: value: The value to format Returns: Formatted value string """ if value is None or value is False: return "Not set" elif isinstance(value, (list, tuple)) and len(value) == 2: # Many2one value return f"{value[1]} (ID: {value[0]})" elif isinstance(value, list): return f"{len(value)} items" elif isinstance(value, bool): return "Yes" if value else "No" else: return str(value)

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/AlejandroLaraPolanco/mcp-odoo'

If you have feedback or need assistance with the MCP directory API, please join our Discord server