# io.github.singleflo/odoo-assistant (pypi · odoo-assistant)

Odoo virtual employee via MCP: query, create, modify records, workflows, PDFs, and activities.

- Trust score: 65/100 (medium)
- Registry status: active
- Liveness: live
- Owner verified: no
- Last scored: 2026-08-20

## Components

- pypi · `odoo-assistant`: 65/100 (this document), [markdown](https://verifymcp.io/servers/singleflo-odoo-assistant/odoo-assistant.md), [page](https://verifymcp.io/servers/singleflo-odoo-assistant/odoo-assistant)

## Channel facts

- Registry: `pypi`
- Package: `odoo-assistant`
- Version: `0.1.1`
- Transport: `stdio`

## Trust breakdown

How this component scores in each security and reliability category. Every signal is checked automatically from public evidence about the published package, including repeated runs of it in an isolated sandbox, and we only credit what we can confirm. Scores are 0–100 per category. Scoring method: https://verifymcp.io/docs/scoring (what has changed: https://verifymcp.io/docs/scoring/changelog)

Scored 2026-08-20.

- **Supply Chain Security**: 100/100
  - No malware found by supply-chain analysis.
  - No known CVEs affecting this package version or its production dependencies.
  - Runs hatchling.build at install time, a recognised native-build step with no shell scripting around it.
  - 2 of 30 dependencies flagged as unhealthy.
- **Provenance & Transparency**: 35/100
  - Source repository is publicly reachable at the declared URL.
  - Provenance check failed: no build-provenance attestation is published.
  - License check failed: the license (MIT License) isn't a recognized OSI-approved license.
  - Actively maintained (last published 1 days ago).
  - Publishes a security disclosure policy (SECURITY.md).
- **Schema Quality & AI Usability**: 81/100
  - 100% of prompts and resources have a non-trivial description (not blank, and not just the item's name).
  - AI-judged instruction clarity (good).
  - Context-footprint check failed: tool/resource definitions use about 3683 tokens (~136/item across 27 items; 19 tools + 8 resources), over budget; trim descriptions and params.
  - Usage-examples check failed: none of the tools include examples.
- **Stability & Change Management**: 0/100
  - Stability not yet verified: not enough scan history yet (needs a 30-day window).
- **Tool Coverage**: 71/100
  - 100% of tools have a non-trivial description (not blank, and not just the tool's name).
  - 0% of tool parameters carry a description.
  - Structured output schemas are declared (100% of tools); any adoption earns full credit.
- **Capabilities**: 100/100
  - Implements a current MCP spec version (2026-07-28).

**Unverified: 1 category.** A category scored 0 because we could not verify it: a data source with nothing on this package, evidence we could not reach, or a check we could not run. We only credit what we can confirm.

## Install

### Claude

```bash
claude mcp add singleflo-odoo-assistant -- uvx odoo-assistant
```

### Codex

```bash
codex mcp add singleflo-odoo-assistant -- uvx odoo-assistant
```

### opencode

```json
{
  "$schema": "https://opencode.ai/config.json",
  "mcp": {
    "singleflo-odoo-assistant": {
      "type": "local",
      "command": [
        "uvx",
        "odoo-assistant"
      ],
      "enabled": true
    }
  }
}
```

### OpenClaw

```bash
openclaw mcp add singleflo-odoo-assistant --command uvx --arg odoo-assistant
```

### Hermes

```yaml
mcp_servers:
  singleflo-odoo-assistant:
    command: "uvx"
    args: ["odoo-assistant"]
```

### Other

```json
{
  "mcpServers": {
    "singleflo-odoo-assistant": {
      "command": "uvx",
      "args": [
        "odoo-assistant"
      ]
    }
  }
}
```

## Changelog

Every change recorded for this component, newest first. Days that predate change tracking, or that we cannot explain, say so: "we were watching and nothing happened" and "we were not watching" are different claims.

### 2026-08-19 (score 65, +15)

- [security improvement] Malware scan: unverified → pass

### 2026-08-18 (score 50)

First indexed and scored.

## MCP tools (19)

### `search_read` (~359 tokens)

Search and read records in one call (Odoo `search_read`).

Two pitfalls this tool cannot fix for you:

\* `account.move` and `account.move.line` mix customer invoices, vendor
  bills, credit notes and raw journal entries. A domain without
  \`move_type` is refused — add `["move_type", "=", "out_invoice"]` (or
  \`in_invoice`, `out_refund`, `in_refund`) so the answer matches what the
  user sees on screen.
\* NEVER sum `amount_total`: it is expressed in each record's own currency,
  and eight foreign-currency invoices once inflated a total 11,9×. Ask for
  \`amount_total_signed` instead — any field with a `_signed` twin is
  stored in company currency, and the twin is the one to add up.

Args:
    model: Odoo model, e.g. "sale.order".
    domain: Odoo domain, e.g. [["state", "=", "sale"]].
    fields: Field names to return. Name them: the default asks for every
        field, which is slow and can fail to serialise on wide models.
    limit: Rows to return. Hard-capped at 200.
    offset: Rows to skip — how to page past a truncated result.
    company_ids: Companies to read from, e.g. [1, 2]. On a multi-company
        instance, omitting this reports one company as the whole business.

Input parameters:

- `company_ids`
- `domain` (array, required)
- `fields`
- `limit` (integer)
- `model` (string, required)
- `offset` (integer)

Output parameters:

- `result` (string)

### `read_record` (~222 tokens)

Read one record by id, always with named fields (writing.md pattern 12).

Omitting `fields` asks for a short list of state fields — never for all of
them: that read is slow at best and fails at worst.

\`account.move` and `account.move.line` are refused here, because the
structural guard wants a `move_type` filter and this tool has nowhere to
put one. Use `search_read` with
\`[["id", "=", <id>], ["move_type", "=", "out_invoice"]]` instead.

And never add up `amount_total` across records — it is in the record's own
currency. `amount_total_signed` is the company-currency twin to sum.

Args:
    model: Odoo model, e.g. "sale.order".
    record_id: The record's database id.
    fields: Field names to read. Omit for the usual state fields.

Input parameters:

- `fields`
- `model` (string, required)
- `record_id` (integer, required)

Output parameters:

- `result` (string)

### `count_records` (~214 tokens)

Count the records matching a domain (Odoo `search_count`).

A count is only as honest as its domain:

\* `account.move` / `account.move.line` without a `move_type` filter is
  refused — it would count invoices, bills, credit notes and journal
  entries together and match no figure the user has ever seen.
\* A count answers "how many", never "how much". For an amount, read
  \`amount_total_signed` (company currency) and never `amount_total`.
\* On a multi-company instance the count differs per company: pass
  \`company_ids` or you are reporting one company as the whole business.

Args:
    model: Odoo model, e.g. "crm.lead".
    domain: Odoo domain. Omit to count everything the model holds.
    company_ids: Companies to count in, e.g. [1, 2].

Input parameters:

- `company_ids`
- `domain`
- `model` (string, required)

Output parameters:

- `result` (string)

### `instance_overview` (~244 tokens)

Summarise the connected instance: version, companies, volumes per area,
in-house modules, anomalies.

The profile is built from the instance this server is CONNECTED to, and
cached per instance — `census.profile_path()` keys it on the live client,
not on an environment variable. That distinction is the whole point: with
two instances profiled on one machine, choosing by `ODOO_DB` (which is
discovered now, so often unset) once fell through to "the first file on
disk" and reported a neighbour's numbers as this instance's, with no error
and a perfectly plausible report.

First call against a new instance builds the profile, which costs a second
or so; every later call is free. Pass `refresh=True` after the instance has
changed — the report carries the timestamp it was taken.

When drilling into these figures, the two rules that keep them meaningful:
filter `account.move` by `move_type`, and sum `amount_total_signed`, never
\`amount_total`.

Args:
    refresh: rebuild the profile from the instance instead of reusing it.

Input parameters:

- `refresh` (boolean)

Output parameters:

- `result` (string)

### `required_fields` (~211 tokens)

List the fields Odoo demands before it will accept a `create`, with the
default it would apply and how existing records actually use it.

Ask this BEFORE `create_record` on a model you have not written to in this
session. The answer is read from the live instance — `fields_get` plus
\`default_get` — never from a table in this file, so a model customised
in-house reports its own requirements.

The dangerous required field is the one that already carries a default: the
create succeeds without you naming it and the record lands wherever the
default points, with no error to notice. `crm.lead.type` is the standing
example — Odoo defaults it to 'lead', and on an instance that works its
pipeline as opportunities that record goes straight to a menu nobody opens.
That is why the live distribution is printed beside each default.

Args:
    model: Odoo model, e.g. "crm.lead".

Input parameters:

- `model` (string, required)

Output parameters:

- `result` (string)

### `create_record` (~162 tokens)

Create a record, reusing an existing match when `unique_on` is given.

\`unique_on` is a list of FIELD NAMES taken from `values` (e.g.
\`["name", "email"]`): they are searched first and the existing id comes
back instead of a duplicate. Odoo has no idempotency key, so a create that
is retried is simply a second record — this is the only protection there
is, and a cold-start run without it produced four identical customers.

Multi-company: put `company_id` in `values`. The context decides what is
visible, not which company owns the new record.

Input parameters:

- `model` (string, required)
- `unique_on`
- `values` (object, required)

Output parameters:

- `result` (string)

### `write_record` (~108 tokens)

Write field values to one record and report what actually changed.

Writing the value a record already holds succeeds and changes nothing; only
the before/after comparison tells that apart from a real update, so that
comparison is the answer.

Setting `active` to False archives the record — the same visible outcome as
deleting it — and is classified destructive rather than as a plain write.

Input parameters:

- `model` (string, required)
- `record_id` (integer, required)
- `values` (object, required)

Output parameters:

- `result` (string)

### `run_action` (~125 tokens)

Run a workflow method and report the state it left behind.

The level follows `method`: confirming or posting is a state change,
cancelling or unlinking is destructive and refused unless the server's
ceiling was raised deliberately.

Two behaviours come from the Writer and are worth knowing: a returned dict
carrying `res_model` is a wizard to follow rather than a result, and a
transition is one-way — calling it twice raises instead of doing nothing.

Input parameters:

- `method` (string, required)
- `model` (string, required)
- `record_ids` (array, required)

Output parameters:

- `result` (string)

### `cancel_record` (~52 tokens)

Cancel a record through `action_cancel`, following the wizard it returns.

Destructive, so the default ceiling refuses it and says what would not.

Input parameters:

- `model` (string, required)
- `record_id` (integer, required)

Output parameters:

- `result` (string)

### `notify_user` (~445 tokens)

Write a note on a record's chatter and notify the users you name.

Both subtypes post a message that IS VISIBLE in the record's chatter. The
difference is who it reaches beyond the people you name.

Args:
    model: the Odoo model, e.g. "sale.order".
    record_id: id of the record to write on.
    message: the body. Send PLAIN TEXT: Odoo escapes anything that arrives
        over RPC, so "<b>x</b>" is displayed as the literal characters
        `<b>x</b>`, not as bold — there is no way to pass real markup
        through this call, and newlines survive but are not turned into
        line breaks. Write the note as prose.
    user_ids: res.users ids to notify. They are notified each through
        their OWN Odoo setting, inbox or email, so naming someone is not a
        promise that no mail leaves.
    subtype: where the message lands.

        | subtype   | visible in the chatter | emails a customer |
        |-----------|------------------------|-------------------|
        | "note"    | yes, internal users    | never             |
        | "inbox"   | NO — notification only | never             |
        | "comment" | yes, everyone          | **YES**           |

        "note" posts `mail.mt_note` and is the default: measured on a real
        order it produced one inbox notification and zero emails.
        "inbox" goes through `message_notify`, which Odoo documents as the
        path for "messages that should not be displayed on a document" —
        the person is notified, the record keeps no trace. "comment" posts
        `mail.mt_comment` and is refused while an external follower
        exists, unless force=True.
    force: post the comment anyway, knowing those people get an email.

Input parameters:

- `force` (boolean)
- `message` (string, required)
- `model` (string, required)
- `record_id` (integer, required)
- `subtype` (string)
- `user_ids` (array, required)

Output parameters:

- `result` (string)

### `create_activity` (~197 tokens)

Schedule an activity: the only notification that carries a deadline.

A chatter note is passive. An activity appears in the assignee's To-Do
list and turns overdue when the date passes.

Args:
    model: the Odoo model, e.g. "crm.lead".
    record_id: id of the record the activity hangs off.
    summary: the one-line title the assignee will read.
    user_id: res.users id of the assignee.
    days: deadline offset from today, in days.
    activity_type: substring of an activity type name, e.g. "call".
        Activity types differ per instance; the first available type is
        used when this is omitted or matches nothing.

Input parameters:

- `activity_type`
- `days` (integer)
- `model` (string, required)
- `record_id` (integer, required)
- `summary` (string, required)
- `user_id` (integer, required)

Output parameters:

- `result` (string)

### `download_docs` (~163 tokens)

Save every document of a record to disk — chatter files included.

Returns {"saved": [paths], "skipped": [[name, why]]}. `skipped` is not
noise: a database restored without its filestore keeps the attachment
rows and loses the bytes, and an empty result would read exactly like
"this record has no attachments".

Args:
    model: the Odoo model, e.g. "account.move".
    record_id: id of the record whose documents to fetch.
    dest_dir: directory to write the files into. Defaults to this
        platform's temporary directory — "/tmp" does not exist on Windows.

Input parameters:

- `dest_dir` (string)
- `model` (string, required)
- `record_id` (integer, required)

Output parameters:

- `result` (string)

### `generate_pdf` (~158 tokens)

Render the PDF of a record and return where it was saved.

An already rendered PDF is reused. Otherwise the model's own print/send
wizard produces it, and that wizard can also SEND the document — which is
why this is gated on `action_send_and_print` (L3_STATE_CHANGE) rather
than as a plain read.

Args:
    model: the Odoo model, e.g. "account.move".
    record_id: id of the record to print.
    dest_dir: directory to write the PDF into. Defaults to this platform's
        temporary directory — "/tmp" does not exist on Windows.

Input parameters:

- `dest_dir` (string)
- `model` (string, required)
- `record_id` (integer, required)

Output parameters:

- `result` (string)

### `list_message_targets` (~242 tokens)

Who can be messaged and where — ASK THIS BEFORE SENDING ANYTHING.

Two lists in one call, because an agent that cannot see the roster invents
ids:

  \* `users`: the internal, active users, each with `im_status` — 'online',
    'away' (idle 30 minutes) or 'offline'. Presence is worth reading first:
    a Discuss message is delivered either way, but "offline" tells you
    nobody is going to answer right now.
  \* `conversations`: the ones the sender already belongs to and has not
    archived, with `channel_type` — 'chat' is a 1-to-1, 'group' is a
    private multi-party, 'channel' is a room that may hold the whole
    company. `members` and `unread` are there so a broadcast is a
    deliberate choice rather than a surprise.

Use `send_direct_message` for a person and `send_channel_message` for a
conversation in this list. Neither of them is the tool for annotating an
invoice or an order — that is `notify_user`.

Output parameters:

- `result` (string)

### `read_conversation` (~133 tokens)

Read what was said in a Discuss conversation, newest first.

This is how you answer "what did they write to me" or "what is going on in
that channel". `list_message_targets` gives you the `channel_id` and says
how many messages are unread.

Reading does not mark anything as read: the unread counter belongs to the
member record and only the user's own client clears it.

Args:
    channel_id: the Discuss channel, from `list_message_targets`.
    limit: how many recent messages to return.

Input parameters:

- `channel_id` (integer, required)
- `limit` (integer)

Output parameters:

- `result` (string)

### `send_direct_message` (~189 tokens)

Send a 1-to-1 Discuss message that appears in the user's chat systray.

This is the tool for "tell X", "message X", "warn X". It opens the private
chat with that user — reusing the existing one, `channel_get` matches on
the exact pair — and posts there. The bus pushes it in real time and it
persists, so a recipient who is offline finds it on their next login.

It reaches them whatever their notification setting says, and sends no
email at all. That is the difference from `notify_user`, which follows the
recipient's preference and lands in the Inbox bell instead.

Args:
    user_id: res.users id of the recipient — from `list_message_targets`.
    message: the body, plain text or simple HTML.

Input parameters:

- `message` (string, required)
- `user_id` (integer, required)

Output parameters:

- `result` (string)

### `send_channel_message` (~146 tokens)

Post to an EXISTING Discuss channel — everyone in it sees this.

The channel is never created here: `list_message_targets` shows the ones
that exist, and posting to a room of the wrong size is not recoverable by
deleting the message afterwards.

Members who are not employees of this instance — portal users, guests —
are named in a refusal rather than written to, the same rule `notify_user`
applies to external followers. Nothing is posted in that case.

Args:
    channel_id: from `list_message_targets`.
    message: the body, plain text or simple HTML.

Input parameters:

- `channel_id` (integer, required)
- `message` (string, required)

Output parameters:

- `result` (string)

### `explore_module` (~152 tokens)

Discover a module's structure by interrogating the live instance.

Args:
    module_name: Module to explore, e.g. "helpdesk". Must be a module
        slug, since it names the reference file on "generate"; ignored
        on "list".
    action: "generate" (the default) writes the reference document,
        "list" ranks what is worth exploring.
    models: Comma-separated models for a module the script does not know,
        e.g. "superchat.message,superchat.template". Defaults to the
        script's own grouping for `module_name`.

Input parameters:

- `action` (string)
- `models` (string)
- `module_name` (string, required)

Output parameters:

- `result` (string)

### `list_known_modules` (~24 tokens)

List the modules this server has learned: name, generation date, records.

Output parameters:

- `result` (string)

## Diagnostics

Captured diagnostic sections: Provenance, Install scripts, Dependencies. The full working is on the page: https://verifymcp.io/servers/singleflo-odoo-assistant/odoo-assistant#diagnostics

## Score history

- 2026-08-20: 65
- 2026-08-19: 65
- 2026-08-18: 50

## Links

- PyPI project: https://pypi.org/project/odoo-assistant/
- Socket report: https://socket.dev/pypi/package/odoo-assistant
- Repository: https://github.com/singleflo/odoo-assistant-mcp
- Changelog RSS feed: https://verifymcp.io/servers/singleflo-odoo-assistant/odoo-assistant.xml
- Changelog JSON feed: https://verifymcp.io/servers/singleflo-odoo-assistant/odoo-assistant.json
- HTML version of this page: https://verifymcp.io/servers/singleflo-odoo-assistant/odoo-assistant
