DEV Community

Feng Yu
Feng Yu

Posted on AI-assisted

Build an Odoo MCP Server: Let AI Agents Query Your ERP (Sales, Inventory, Customers)

description: "A practical, ~200-line guide to building a read-only MCP server that connects Claude Desktop, Cursor and Windsurf to Odoo ERP via XML-RPC — with working Python code, Docker deployment and Claude Desktop config."

Most public MCP servers are generic — file readers, web scrapers, git helpers. Useful for developers, useless for business teams.

If you run an ERP, your business data — sales orders, inventory, customers — lives behind XML-RPC APIs that no AI agent can reach out of the box. Odoo is the most popular open-source ERP, and its users keep answering the same questions over and over: "What's the stock?", "Which orders are unpaid?", "Who are our top customers?"

This post shows how to build a read-only MCP server that connects Claude Desktop / Cursor / Windsurf to Odoo in about 200 lines of Python. No vendor lock-in, no custom dashboards, no spreadsheet exports.

What is MCP in 30 seconds

Model Context Protocol (MCP) is an open standard (introduced by Anthropic in late 2024) that lets AI assistants call external tools in a uniform way. A server exposes tools; clients like Claude Desktop or Cursor discover them and let the model invoke them through natural language.

AI Client (Claude Desktop / Cursor / Windsurf)
        │  MCP over stdio
        ▼
Odoo MCP Server  (Python + MCP SDK)
        │  XML-RPC
        ▼
Odoo ERP  (sale.order · stock.quant · res.partner · product.product)
Enter fullscreen mode Exit fullscreen mode

Project structure

odoo-mcp/
├── server.py          # MCP tools definition
├── odoo_client.py     # thin Odoo XML-RPC wrapper
├── requirements.txt
├── Dockerfile
└── .env               # credentials — never commit this
Enter fullscreen mode Exit fullscreen mode

Implementation

1. Dependencies

mcp>=2.0
python-dotenv>=1.0.0
Enter fullscreen mode Exit fullscreen mode

2. Odoo client — odoo_client.py

A thin wrapper around Odoo's XML-RPC endpoint. Two details matter in production:

  • API keys: use an Odoo API key (Settings → Users → your profile → API Keys, available since Odoo 17) instead of a plain password.

  • JSON-safe results: Odoo returns datetime and tuple objects that Python's json cannot serialize directly. MCPServer serializes tool results to JSON, so we normalize everything before returning.

"""Minimal Odoo XML-RPC client with JSON-safe result normalization."""
import datetime
import xmlrpc.client
from typing import Any


def _to_json_safe(value: Any) -> Any:
    if isinstance(value, (datetime.datetime, datetime.date, datetime.time)):
        return value.isoformat()
    if isinstance(value, tuple):
        return [_to_json_safe(v) for v in value]
    if isinstance(value, dict):
        return {k: _to_json_safe(v) for k, v in value.items()}
    if isinstance(value, (list, set)):
        return [_to_json_safe(v) for v in value]
    return value


class OdooClient:
    def __init__(self, url: str, db: str, username: str, api_key: str) -> None:
        self.url = url.rstrip("/")
        self.db = db
        self.username = username
        self.api_key = api_key
        self._uid: int | None = None
        self._models = xmlrpc.client.ServerProxy(f"{self.url}/xmlrpc/2/object")

    @property
    def uid(self) -> int:
        if self._uid is None:
            common = xmlrpc.client.ServerProxy(f"{self.url}/xmlrpc/2/common")
            self._uid = common.authenticate(self.db, self.username, self.api_key, {})
            if not self._uid:
                raise PermissionError("Odoo authentication failed")
        return self._uid

    def execute(self, model: str, method: str, args: list, kwargs: dict) -> Any:
        return self._models.execute_kw(
            self.db, self.uid, self.api_key, model, method, args, kwargs
        )

    def search_read(
        self, model: str, domain: list, fields: list[str], limit: int = 100
    ) -> list[dict]:
        rows = self.execute(
            model, "search_read", [domain], {"fields": fields, "limit": limit}
        )
        return [_to_json_safe(row) for row in rows]
Enter fullscreen mode Exit fullscreen mode

3. MCP server — server.py

We expose six read-only tools covering the questions Odoo users ask most. No write tools in the MVP — read-only keeps the attack surface small and makes the open-source version safe to run against a production database.

"""Odoo MCP server — read-only tools for AI agents."""
import os

from dotenv import load_dotenv
from mcp.server.mcpserver import MCPServer

from odoo_client import OdooClient

load_dotenv()

mcp = MCPServer("odoo-mcp")

_client: OdooClient | None = None


def client() -> OdooClient:
    global _client
    if _client is None:
        _client = OdooClient(
            url=os.environ["ODOO_URL"],
            db=os.environ["ODOO_DB"],
            username=os.environ["ODOO_USER"],
            api_key=os.environ["ODOO_API_KEY"],
        )
    return _client


@mcp.tool()
def list_odoo_models(limit: int = 200) -> list[str]:
    """List available Odoo business models, e.g. sale.order, stock.quant, res.partner."""
    rows = client().search_read("ir.model", [], ["model"], limit=limit)
    return [r["model"] for r in rows]


@mcp.tool()
def search_partners(name: str = "", email: str = "", limit: int = 20) -> list[dict]:
    """Search customers and suppliers by name or email."""
    domain = [("is_company", "=", True)]
    if name:
        domain.append(("name", "ilike", name))
    if email:
        domain.append(("email", "ilike", email))
    fields = ["name", "email", "phone", "create_date"]
    return client().search_read("res.partner", domain, fields, limit=limit)


@mcp.tool()
def search_sales_orders(
    state: str = "", customer: str = "", limit: int = 20
) -> list[dict]:
    """Search sales orders by state (draft, sent, sale, done) or customer name."""
    domain: list = []
    if state:
        domain.append(("state", "=", state))
    if customer:
        domain.append(("partner_id.name", "ilike", customer))
    fields = ["name", "partner_id", "amount_total", "state", "date_order"]
    return client().search_read("sale.order", domain, fields, limit=limit)


@mcp.tool()
def search_products(name: str = "", sku: str = "", limit: int = 20) -> list[dict]:
    """Search sellable products by name or internal reference (SKU)."""
    domain = [("sale_ok", "=", True)]
    if name:
        domain.append(("name", "ilike", name))
    if sku:
        domain.append(("default_code", "=", sku))
    fields = ["name", "default_code", "list_price"]
    return client().search_read("product.product", domain, fields, limit=limit)


@mcp.tool()
def get_stock_quant(sku: str = "", name: str = "", limit: int = 20) -> list[dict]:
    """Query real-time on-hand stock by product SKU or name."""
    product_domain: list = []
    if sku:
        product_domain.append(("default_code", "=", sku))
    if name:
        product_domain.append(("name", "ilike", name))

    products = client().search_read(
        "product.product", product_domain, ["id", "name", "default_code"], limit=limit
    )
    if not products:
        return []

    ids = [p["id"] for p in products]
    quants = client().search_read(
        "stock.quant",
        [("product_id", "in", ids), ("location_id.usage", "=", "internal")],
        ["product_id", "location_id", "quantity", "reserved_quantity"],
        limit=limit,
    )
    for q in quants:
        q["product_name"] = next(
            (p["name"] for p in products if p["id"] == q["product_id"][0]), ""
        )
    return quants


@mcp.tool()
def get_sale_order_detail(order_name: str) -> dict:
    """Get a full sales order by its name, including line items and totals."""
    orders = client().search_read(
        "sale.order", [("name", "=", order_name)], ["id", "name", "amount_total", "state"], limit=1
    )
    if not orders:
        return {"error": f"Order {order_name} not found"}

    order_id = orders[0]["id"]
    order = client().search_read(
        "sale.order",
        [("id", "=", order_id)],
        ["name", "partner_id", "amount_total", "state", "date_order"],
        limit=1,
    )[0]

    lines = client().search_read(
        "sale.order.line",
        [("order_id", "=", order_id)],
        ["product_id", "product_uom_qty", "price_unit", "price_subtotal"],
        limit=200,
    )
    order["lines"] = lines
    return order


if __name__ == "__main__":
    mcp.run()
Enter fullscreen mode Exit fullscreen mode

4. Configuration — .env

ODOO_URL=https://your-odoo-instance.odoo.com
ODOO_DB=your-db-name
ODOO_USER=your-login@example.com
ODOO_API_KEY=your-api-key
Enter fullscreen mode Exit fullscreen mode

Create the API key

in Odoo: Settings → Users → your profile → API Keys. For production, create a

dedicated read-only user

and grant access only to the models you intend to expose.

5. Run locally

python -m venv .venv
source .venv/bin/activate        # Windows: .venv\Scripts\activate
pip install -r requirements.txt
python server.py
Enter fullscreen mode Exit fullscreen mode

The server now listens on stdio. Point your MCP client at it.

6. Docker deployment

FROM python:3.12-slim

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .

CMD ["python", "server.py"]
Enter fullscreen mode Exit fullscreen mode
docker build -t odoo-mcp .
docker run --rm -i --env-file .env odoo-mcp
Enter fullscreen mode Exit fullscreen mode

7. Connect Claude Desktop

Add the server to claude_desktop_config.json (Claude → Settings → Developer → Edit Config):

{
  "mcpServers": {
    "odoo-mcp": {
      "command": "docker",
      "args": ["run", "--rm", "-i", "--env-file", "/absolute/path/to/.env", "odoo-mcp"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Restart Claude Desktop and try:

  • "Show all confirmed sales orders this month, grouped by customer."

  • "How much stock of SKU PROD-001 is available in warehouse WH01?"

  • "Find customers from Germany with unpaid orders."

  • "Show me the full detail of order SO00123, including line items."

Security notes

  • Read-only by default. The MVP exposes no write tools. Write-back (create quotation, create partner, update notes) belongs in a paid tier backed by a full audit log.

  • Least privilege. Create a dedicated Odoo user with read access only to the models you expose. Do not reuse an admin account.

  • Model whitelist (recommended). Add an ODOO_ALLOWED_MODELS env var and filter in client() to block sensitive models like hr.employee or account.move.line.

  • Never commit .env. Add it to .gitignore.

Roadmap

  • [x] Read-only queries (MVP)

  • [ ] Write tools: create quotation / partner, with audit logging

  • [ ] Multi-instance support (multiple Odoo databases)

  • [ ] Model whitelist configuration

  • [ ] Managed hosting for non-technical teams

Why this matters for Odoo teams

Odoo consultants, small manufacturers and trading companies constantly answer the same questions: "What's the stock?", "Which orders are unpaid?", "Who are our top customers?" An MCP server turns those into a chat conversation while keeping the data live — no exports, no spreadsheets, no custom dashboards.

It is also a practical business opportunity: generic MCP servers are hard to monetize (they are commoditized), but vertical ERP connectors have clear value because they remove custom scripting work for real customers. The read-only MVP is the foundation for paid tiers (write access, audit logs, hosting) and custom integrations (manufacturing BOM, quality, MRP).

Conclusion

MCP turns an ERP from a database into a conversation. With roughly 200 lines of Python you get a working read-only Odoo MCP server that works across Claude Desktop, Cursor and Windsurf — and you keep full control of your data and credentials.


This is part of an open-source project — github.com/fengyuGbt/odoo-mcp. Star the repo, open issues, or contact me for custom Odoo MCP integrations (write-back, audit logs, manufacturing modules).

Top comments (0)