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 (3)

Collapse
 
scoretracker4321 profile image
scoretracker4321 •

Useful walkthrough, and keeping the first version read-only is the right call. Three notes from building the same kind of connector:

  1. Read-only is safer when Odoo enforces it as well. Give the server its own Odoo user with read access only on those models. Access rights and record rules then apply, and a bug or a tool added later cannot write.

  2. XML-RPC has an end date. The Odoo 19 documentation says /xmlrpc and /jsonrpc are scheduled for removal in Odoo 22 (fall 2028) and in Online 21.1, with the JSON-2 API as the replacement. Keeping the client behind one small class, as you have, makes that swap easy.

  3. A small one: API keys are older than 17. The res.users.apikeys model is already in the 14.0 source, so the same code works for people on 14 to 16.

Did you run into context-size trouble with search_read at limit 100, or did the explicit field lists keep it small enough?

Collapse
 
fengyugbt profile image
Feng Yu •

Agreed — and that's exactly what the Security notes recommend: give the server its own Odoo user with read access only to the exposed models. Access rights + record rules then do the enforcement server-side, so a future write tool or a bug can't escalate. The MVP stays read-only precisely so the blast radius stays small while the ACL story is being sorted out.
You're right. The Odoo 19 docs list /xmlrpc, /xmlrpc/2 and /jsonrpc as deprecated, scheduled for removal in Odoo 22 (fall 2028) and Online 21.1, with the JSON-2 API (/json/2) as the replacement. That's exactly why all RPC calls sit behind the single OdooClient class — swapping the transport later is a one-file change.
Thanks for the correction — res.users.apikeys goes back further than 17. I'll adjust the article to say the model has existed since 14, with in-UI API key management arriving in 17.
On your question: no context-size trouble so far. Every tool uses an explicit field list, and the defaults are deliberately small — search tools default to limit 20, list_odoo_models to 200 but returns only model names. A typical response is a few hundred bytes, so even at limit 100 with those field lists it stays well within the token budget. If I ever expose an unbounded list, I'd add pagination before worrying about the model context window.

Collapse
 
scoretracker4321 profile image
scoretracker4321 •

Thanks for the detailed answers, the OdooClient split and the small defaults make a lot of sense.

One small thing before you edit the article: I believe the API key screen also goes back to 14. The 14.0 External API docs already describe creating keys under Preferences > Account Security > New API Key, so "since 14" may cover both the model and the UI.

And a tip from my own connector: if a tool ever lets the model pick the fields, block image and binary fields like image_1920. One product record can come back as hundreds of KB of base64. Your explicit field lists already avoid that, so it only matters if that option is added later.