Skip to main content
Glama
mohamedabuemira

Odoo MCP Bridge

README.md
# Odoo MCP Bridge

An MCP server that sits **outside** Odoo and reaches it over the external API.
Nothing is installed in the database, so it works on Odoo Online, where custom
modules are impossible, as well as on Odoo.sh and on-premise.

One HTTPS endpoint, `POST /mcp`, stateless Streamable HTTP. Eight tools:
`odoo_list_models`, `odoo_describe_model`, `odoo_search`, `odoo_aggregate`,
`odoo_create`, `odoo_update`, `odoo_delete`, `odoo_call_method`.

```
Claude / ChatGPT  ──HTTPS + bearer──▶  this bridge  ──HTTPS + API key──▶  Odoo
                                       (your host)                  (Online / .sh)
```

---

## Read this before deploying anything

The bridge holds an Odoo API key, and **an API key carries every right its
user has.** The permission matrix in `config.yaml` runs in this process, in
front of that key. It stops an assistant from doing something unintended. It
does **not** stop anyone who obtains the key.

So the matrix is the second line, not the first. The first line is the Odoo
side:

1. Create a dedicated user — `mcp.bot@company.com` — not a person's account.
2. Give it the narrowest groups that let it do the job. Never Settings.
3. Write record rules on that user for anything sensitive.
4. Clear its password so the account cannot be used to log in at all; the API
   key becomes the only way in.
5. Generate the key: **Preferences → Account Security → New API Key**.

Whatever survives that is what the bridge is working with. Sell it that way,
and the matrix on top is a genuine extra rather than a claim that collapses
the first time someone looks at it.

---

## Quick start

```bash
git clone <your repo> && cd odoo-mcp-bridge
pip install -r requirements.txt
cp config.example.yaml config.yaml     # then edit it

export MCP_TOKEN_DEMO=$(python -c "import secrets;print(secrets.token_urlsafe(32))")
export ODOO_API_KEY_DEMO=<the key from Odoo>
export MCP_CONFIG=config.yaml

uvicorn app.main:app --reload --port 8000
```

Check that Odoo actually answers:

```bash
curl localhost:8000/readyz
```

Then call a tool by hand before pointing an assistant at it:

```bash
curl -s localhost:8000/mcp \
  -H "Authorization: Bearer $MCP_TOKEN_DEMO" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
       "params":{"name":"odoo_list_models","arguments":{}}}' | jq
```

Tests:

```bash
pip install pytest && pytest -q
```

## Connecting an assistant

The endpoint is `https://<your-host>/mcp` with an
`Authorization: Bearer <the token from config.yaml>` header. Anything that
speaks remote MCP with a custom header will connect — Claude's custom
connectors, Claude Code (`claude mcp add --transport http odoo <url> --header ...`),
and the ChatGPT developer-mode connector among them.

Two tokens are involved and they are easy to confuse:

| Token | Who holds it | What it proves |
|---|---|---|
| `token:` in `config.yaml` | the assistant | may talk to this bridge, as this tenant |
| `odoo.api_key` | this bridge | may talk to that Odoo database, as the bot user |

Never give the second one to an assistant.

## Configuration

`config.example.yaml` is the reference; it is commented as documentation.
The shape:

```yaml
tenants:
  - id: acme
    token: env:MCP_TOKEN_ACME
    read_only: true                  # a good place to start
    odoo:
      url: https://acme.odoo.com
      db: acme
      transport: json2               # Odoo 19+. Use xmlrpc for 17/18.
      api_key: env:ODOO_API_KEY_ACME
    models:
      sale.order:
        read: true
        write: false
        domain: [["state", "not in", ["draft"]]]   # applied to reads AND writes
        methods: [action_confirm]                   # ORM verbs are refused here
        fields_deny: [margin]
```

Every secret is `env:NAME`. Nothing sensitive goes in the file, so the file
belongs in git and the secrets belong in the platform's secret store.

Design points worth knowing:

- **A model not listed does not exist.** No defaults, no inheritance.
- **`domain` is a boundary, not a filter.** It is ANDed into every read *and*
  every id is resolved through it before any write, delete or method call. A
  caller holding an id from elsewhere still cannot touch a record the domain
  excludes.
- **The AND is explicit.** Concatenating a caller's domain onto a policy
  domain is unsafe: a caller who sends `["|", leaf]` turns the join into an
  OR and the policy stops applying. Domains are validated for balance and
  joined with an explicit `&`. There is a test for exactly this.
- **ORM verbs cannot be whitelisted as methods.** `write`, `unlink`, `copy`
  and friends are refused at config-load time, so the matrix stays the only
  way to change data.
- **A business method needs `write`.** Not `read`. A model marked read-only
  cannot confirm an order.
- **Deleting needs `confirm: true`** as a separate acknowledgement.
- **Field masking** (`fields_deny` / `fields_allow`) applies to reading,
  writing, describing, grouping and aggregating — not just to the row output.

## Transports

| Odoo version | `transport` | Notes |
|---|---|---|
| 19+ | `json2` | `/json/2/<model>/<method>`, bearer key, named args |
| 17, 18 | `xmlrpc` | also needs `odoo.username` |

XML-RPC and JSON-RPC are deprecated as of Odoo 19 with removal announced for
20, so `json2` is where new work should go.

Two consequences of JSON-2 shape what this bridge can offer. Each call is its
own SQL transaction, so nothing here can make two calls atomic — anything
needing atomicity has to be one business method on the Odoo side, which is
why `odoo_call_method` exists. And on Odoo Online, the external API is
documented as available on **Custom plans only**, not One App Free or
Standard. Confirm the client's plan before promising anything.

## The audit trail

One JSON object per line, on stdout and optionally to a file: who, which
tool, which model, which records, how long, and the arguments with anything
password-shaped redacted. Failed authentications are logged too, which is the
first thing an auditor asks for.

It is evidence, not proof. A file on a host you control can be edited. If a
client needs the stronger claim, chain the entries by carrying each line's
SHA-256 into the next and publish the head digest somewhere you cannot reach.
Until that exists, do not call it immutable.

---

## Where to host it, free

The bridge is stateless and tiny, which is what makes free hosting realistic
at all. Ranked for this specific job.

### 1. Google Cloud Run — best overall

Perpetual free tier (~2M requests/month), scales to zero, cold starts around
a second, real HTTPS, secrets in Secret Manager. Needs a billing account on
file, which is the only catch, and at this traffic the bill stays at zero.

```bash
gcloud run deploy odoo-mcp --source . --region us-central1 \
  --allow-unauthenticated --max-instances 2 \
  --set-env-vars MCP_CONFIG=/app/config.yaml \
  --set-secrets ODOO_API_KEY_ACME=odoo-key:latest,MCP_TOKEN_ACME=mcp-token:latest
```

Use `us-central1`: the free allowance is region-dependent. Keep
`--max-instances` low and set a $1 budget alert — Cloud Run has no hard cap,
it bills. See DEPLOY.md.

### 2. Oracle Cloud Always Free — best if you want it always on

A genuinely free-forever ARM VM (up to 4 cores / 24 GB). Always on, no cold
starts, no request caps. You manage the box and the TLS certificate — Caddy
makes that two lines. The right answer for anything a client depends on.

### 3. Hugging Face Spaces (Docker SDK) — easiest, no card

Free CPU tier, generous RAM, no credit card, secrets under **Settings →
Variables and secrets**. Must listen on **7860**, which the Dockerfile
already does. Caveats: free Spaces are public and sleep after long
inactivity, and it is built for demos rather than business APIs. Fine for
testing and demos; do not put a client's production key there.

### 4. Render — free web service

512 MB, 0.1 vCPU, deploy from GitHub, `render.yaml` included. The problem for
this use case is that free services **spin down after 15 minutes idle** and
take up to a minute to wake — and an MCP client will report that as a failed
connection, not as a slow one. Workable only with a pinger hitting `/healthz`
every 10 minutes, which eats most of the monthly hour allowance.

### 5. Koyeb — free, scale-to-zero

One free service, 0.1 vCPU / 512 MB. Same cold-start caveat as Render.

**No longer free:** Heroku and Fly.io. **Avoid:** PythonAnywhere's free tier —
outbound network is restricted to an allowlist, so it cannot reach a client's
Odoo domain at all.

### The honest version

Free tiers are for your demos and your own testing. The moment a paying
client's API key is involved, a €4/month VPS or Cloud Run with a billing
account is the responsible choice — not because free hosts are badly run, but
because a shared free host with no SLA is a strange place to keep a
credential that can read someone's whole database. Free tiers also disappear
with a month's notice; two of the ones on every list a year ago are gone.

Whatever you pick, three things are non-negotiable: **HTTPS**, secrets in the
platform's secret store rather than in the repo, and a long random `token:`
per tenant.

## Roadmap

- OAuth 2.1 with PKCE, so each person authorises separately instead of
  sharing one bearer token
- Hash-chained audit entries
- `outputSchema` on the tool definitions
- Per-tool rate limits rather than one bucket per tenant

## Licence

Choose one before publishing. If this is going to be sold alongside services,
AGPL-3 or a commercial licence; if it is a lead magnet, MIT.