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)
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
Implementation
1. Dependencies
mcp>=2.0
python-dotenv>=1.0.0
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
datetimeandtupleobjects that Python'sjsoncannot 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]
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()
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
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
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"]
docker build -t odoo-mcp .
docker run --rm -i --env-file .env odoo-mcp
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"]
}
}
}
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_MODELSenv var and filter inclient()to block sensitive models likehr.employeeoraccount.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)