Skip to main content
Glama
README.md
# odoo-hands

**Give your AI agent hands on Odoo.** An MCP server that drives a **running Odoo web client** (Odoo 16 and Odoo 8)
through its UI, the way a user would — inspired by [Marionette MCP](https://github.com/leancodepl/marionette_mcp) for Flutter.

> "Open Sales > Quotations, create a quotation for Azure Interior with 3 office chairs, then confirm it."

The agent sees a compact description of the current screen (fields, buttons, tabs, statusbar, list rows) and acts with a
small set of tools that speak Odoo: menus, fields and buttons by technical name, notebook tabs, editable list lines.
No CSS selectors, no per-ticket code. Server errors (UserError, tracebacks) are intercepted from the JSON-RPC responses
and reported with every action.

Nothing is installed in Odoo. The agent logs in like a person, gets that user's rights and nothing more, and every
action goes through the same onchanges and validations as a human click — which is the point: reproduce a bug the way
the user met it, validate a screen after a change, collect screenshots for a test report, and turn the session into an
Odoo `web_tour` test.

Other Odoo MCP servers give agents *eyes* on the data (XML-RPC / JSON-RPC CRUD). This one gives them *hands* on the
interface; `orm_call` is there for the data side when you need both.

## Demo

Recorded against the bundled vanilla Odoo 16.0 (`docker compose up`). Each clip is one
prompt to Claude Code; the browser on the left is driven entirely through its UI, and
the terminal on the right is what the agent reports back. The source files are in
[`docs/demo/`](docs/demo/).

### Full walkthrough (5 min)

The five use cases below, back to back, in one uninterrupted session.

https://github.com/user-attachments/assets/90f689ed-c13e-4438-b973-214268f6a0d0

### Create a quotation and confirm it
> Connect to Odoo, open Sales > Orders > Quotations, create a quotation for Azure Interior with 3 Large Desk and 2 Cabinet with Doors, save it and confirm it. Then tell me the order number and its state.

https://github.com/user-attachments/assets/3b70f891-dbec-4f5f-b8cd-4dac0d432f7a

### Hit a server error, read it, then recover
> Now try to delete that order from the Actions menu and tell me exactly what Odoo answers.

Odoo refuses ("You can not delete a sent quotation or a confirmed sales order…"); the
message is intercepted and reported. A follow-up — "so cancel it first and delete" —
cancels the order (handling the *Cancel Sales Order* wizard without sending the email)
and deletes it.

https://github.com/user-attachments/assets/6500197c-815a-4979-b9df-785a67fa92b3

### Read a screen
> Open Inventory > Products > Products, open "Large Desk" and give me its sales price, cost and the quantity on hand.

https://github.com/user-attachments/assets/b9e36445-1879-429a-8d88-6594fc7c8d71

### Find a record and screenshot it
> Go to Contacts, search for Deco Addict, open it and take a screenshot of the form.

The Contacts app is not installed on this database; the agent notices and reaches the
same record through Sales > Orders > Customers.

https://github.com/user-attachments/assets/ab43acc6-4a65-4356-85e2-07c49f093801

### Query the data, then open a record
> How many quotations are still in draft? Open the most recent one.

`orm_call` counts them; the most recent is then opened through the UI.

https://github.com/user-attachments/assets/65637da1-2c3a-4bfa-bc84-3926a1300a24

## Requirements

- Python 3.12 + [uv](https://docs.astral.sh/uv/)
- A running Odoo 16 (16.0 and saas~16.4 tested) or Odoo 8 (8.0 tested) reachable on localhost — or the bundled
  `docker-compose.yml` (Odoo 16.0 + demo data on port 8069)
- Chromium for Playwright (`uv run playwright install chromium` if missing)

## Setup

```bash
git clone https://github.com/<you>/odoo-hands && cd odoo-hands
uv sync
cp .env.example .env        # then set ODOO_URL / ODOO_DB / ODOO_LOGIN / ODOO_PASSWORD
```

Start your Odoo instance as usual (the server never starts Odoo itself), then register the MCP server in Claude Code
(user scope, so it is available from any project):

```bash
claude mcp add --scope user --transport stdio odoo-hands -- \
  uv --directory /path/to/odoo-hands run odoo-hands
```

Append `--headless` after `odoo-hands` to hide the browser window. By default a Chromium window opens so you can watch
the agent work. Any MCP client that speaks stdio works the same way (`uv run odoo-hands`).

## Tools

| Tool | What it does |
|---|---|
| `connect(url?, db?, login?, password?, headless?)` | Open the browser, log in, check the Odoo version. Defaults from `.env`. Localhost only unless `ODOO_MCP_ALLOW_REMOTE=1`. |
| `disconnect()` | Close the browser; returns the session's `.webm` when `ODOO_MCP_VIDEO_DIR` is set. |
| `where_am_i()` | Menu path, model, record id, view type, record state, dirty/new flags, open dialog, notifications. |
| `get_elements(scope?, include?, include_empty?)` | Fields (name, label, type, widget, value, required/readonly/invalid), buttons (name, label, kind), tabs, statusbar, x2many lists with rows, list/kanban rows, facets, pager. Scoped to the open dialog when there is one. |
| `open_menu(path)` | `"Ventes > Commandes > Devis"` or an xmlid such as `sale.menu_sale_quotations`. |
| `open_record(model, res_id?, view_type?)` | Open a record form, a list/kanban, or a blank new record. |
| `set_field(name, value, line?, list_field?)` | Set a field by technical name: text, numbers, many2one (autocomplete), selection, boolean, date/datetime (ISO), many2many tags, html. Switches tab automatically. `line`/`list_field` target a cell of an editable list (a one2many, or the list view itself when `list_field` is omitted); hidden optional columns are enabled on demand. |
| `add_line(field, values, create?)` | Add and fill a line in a one2many (inline editable list, or popup form fallback). Reports `invalid_cells` when the row cannot be committed, or `kept_in_edition` when the list lives in a dialog (Odoo commits those rows on save). |
| `click_button(name, timeout?)` | Header/smart/inline buttons by name, `new` / `save` / `discard`, statusbar states, notebook tabs, dialog buttons, or any button by label. |
| `click_row(index_or_text, list_field?)` | Open a list/kanban row (by index or by text); the click lands on a plain text cell, never on a star/checkbox/handle widget. In a *Search More* dialog it selects the record. |
| `delete_line(field, line)` | Remove a row of a one2many list (index or `"editing"`) via its trash icon. |
| `action_menu(item)` | Pick an entry of the cog / Actions dropdown by label (Delete, Duplicate, Archive…). |
| `search(text, clear?, field?)` | Type in the search box; `field` picks the facet ("Product", "Customer"…) instead of the default one. |
| `save()` | Save the form (or form dialog); reports invalid required fields. |
| `open_url(path)` | Navigate to any path of the Odoo host: kiosk / website pages (`/wms/reception`) are driven as plain HTML, `/web` comes back to the client. |
| `screenshot(path?, full_page?)` | PNG of the browser, returned as an image and saved under `screenshots/`. |
| `orm_call(model, method, args?, kwargs?)` | Server-side call through the logged-in session (prepare or verify data). |
| `evaluate_js(expression)` | Escape hatch, disabled unless `ODOO_MCP_ALLOW_JS=1`. |
| `export_tour(name, format?, module?)` | Turn the session's actions into an Odoo `web_tour` (JS file + `HttpCase` test + manifest line). |
| `run_tour(name?, steps_js?)` | Replay the recording (or given steps) as a tour inside the open browser, to validate an export. |
| `get_errors(since?, clear?)` | Intercepted RPC errors with tracebacks, JS errors, error dialogs, error notifications. |

Every action returns `changes` (state transition, navigation, dialog opened/closed, notifications, downloads),
`errors` raised during the action, and the resulting `location`.

## Profiles

The version is detected at `connect` and selects a profile (`profiles/`): every selector and page hook lives there.

| Profile | Detected on | Notes |
|---|---|---|
| `odoo16` | Odoo 16.0 and saas~16.x web client (OWL) | Fields by `name`, menus via the menu service, xmlids known client-side. |
| `odoo8` | Odoo 8 web client (`openerp.client`) | Widgets are tagged from the JS widget tree (`data-mcp-field` / `data-mcp-button`). Forms open **read-only**: `set_field` / `add_line` click *Edit* automatically. Menu xmlids are resolved through `ir.model.data`. many2many tags and html (CKEditor) fields are not supported. |
| `plain` | Any other page of the host (kiosk `/wms/*`, website) | Inputs by `name`/`id`, buttons and links by label, tables as rows. |

Odoo 8 example (`.env` or `connect` arguments):

```bash
ODOO_URL=http://localhost:8069 ODOO_DB=my_odoo8_db ODOO_LOGIN=admin ODOO_PASSWORD=admin
```

On a large database (prod copy) list loads and saves can take tens of seconds: raise `ODOO_MCP_TIMEOUT_MS` or pass
`timeout` to `click_button` / `save`.

## Example prompts

- "Connect to Odoo, open Sales > Quotations and create a quotation for *customer* with 3 *product*, then confirm it."
- "Open order S00042 and tell me why confirming it fails."
- "Go to Inventory > Transfers, open the first waiting transfer and take a screenshot."
- "Replay what you just did as a web_tour and give me the HttpCase test."

Menu labels are matched without accents or case, in whatever language the user's Odoo runs in.

## Tests

```bash
uv run pytest -m "not live"      # unit tests, no Odoo needed
docker compose up -d             # optional: vanilla Odoo 16.0 with demo data on http://localhost:8069 (db odoo_hands, admin/admin)
uv run pytest -m live            # against the instance in .env (skipped when unreachable); add --keep to keep test records
MCP_TEST_HEADED=1 uv run pytest -m live -k smoke     # watch the smoke scenario in a window
ODOO8_URL=http://localhost:8069 ODOO8_DB=my_odoo8_db uv run pytest -m live tests/test_v8_profile.py
```

Live tests need `sale_management` (and `purchase` for one regression test) and pick their partner / product from the
database; set `ODOO_TEST_PARTNER` / `ODOO_TEST_PRODUCT` to choose them. Required custom fields are filled on the fly.

Live tests create quotations tagged `client_order_ref = MCP-SMOKE-<timestamp>` and delete them afterwards (their
pickings too, when a test confirms an order). `tests/test_regressions.py` replays the failures met in real sessions.

## Limits

- Odoo 16 and Odoo 8 (`profiles/v16.py`, `profiles/v8.py` hold every selector; other versions are separate profiles).
- Many2one values are picked from the autocomplete suggestions (exact > code segment > prefix > contains); use
  `{"index": 0}`, `{"create": "Name"}` or `{"search_more": "query"}` for the other dropdown entries.
- `export_tour` / `run_tour` target the OWL tour service (saas~16.4, 17+). On 16.0 the exported steps still apply,
  but they must be registered with the legacy `tour.register` and started with `odoo.startTour`.
- Odoo's onboarding tour bubbles (`.o_tour_pointer`) are hidden in the driven browser: they intercept clicks next to
  their target. Dates are committed with a `change` event (Enter would add a row in editable lists).
- The browser is a single page: one action at a time, one MCP session per Odoo instance.
- Passwords never appear in tool outputs or logs; keep `.env` out of git (it is ignored).

## License

MIT — see `LICENSE`. Odoo is a trademark of Odoo S.A.; this project is not affiliated with or endorsed by Odoo.

TDQS

A3.7/5.0

Scored across 21 tools

Disambiguation4/5

Most tools target distinct actions (navigation, form editing, list manipulation, debugging), but some overlap exists: 'open_menu' vs 'open_record' vs 'open_url' all navigate, and 'action_menu' vs 'click_button' both handle dropdown/button actions. 'get_elements' and 'where_am_i' both describe the current screen, though with different granularity.

Naming Consistency4/5

The naming is predominantly verb_noun (open_menu, set_field, add_line, delete_line, click_button, click_row, export_tour, run_tour, get_errors), with a few exceptions like 'where_am_i' and 'orm_call' that break the pattern. Overall the convention is clear and predictable.

Tool Count4/5

21 tools is on the higher end but justified for a browser automation server covering navigation, form interaction, list editing, debugging, and tour export/replay. A few tools (evaluate_js, orm_call, export_tour, run_tour) are advanced escape hatches that could be optional, but they serve distinct purposes.

Completeness5/5

The tool set covers the full lifecycle of UI automation: connect/disconnect, orientation (where_am_i), element discovery (get_elements), navigation (open_menu, open_record, open_url), form filling (set_field, add_line, delete_line), actions (action_menu, click_button, click_row, search, save), verification (screenshot, get_errors), and even test export/replay. No obvious dead ends for the stated purpose of driving an Odoo instance.

Maintenance

ActivityMaintained
ResponsivenessUnresponsive