xippios-cashctrl-mcp
by xippios
README.md
# xippios-cashctrl
Typed Python client, MCP server and FastAPI routes for
[CashCtrl](https://cashctrl.com), part of the
[xippios SDK family](https://github.com/xippios). Names and semantics follow the
upstream documentation; every response carries release metadata (source, endpoint,
retrieved_at, published_at, vintage).
## Install
```bash
uv add xippios-cashctrl # client only
uv add "xippios-cashctrl[mcp]" # + MCP server
uv add "xippios-cashctrl[http]" # + FastAPI router
```
## Configuration
CashCtrl serves every organisation from its own subdomain
(`https://<subdomain>.cashctrl.com/api/v1`) and gives every organisation its own API
user. The package therefore works with named organisations, declared in the environment;
nothing is read from files.
| Variable | Meaning |
| --- | --- |
| `CASHCTRL_ORGS` | the organisations, comma-separated, lower-case letters, digits and `_`: `acme,acme_test` |
| `CASHCTRL_<NAME>_SUBDOMAIN` | per organisation, the subdomain: `CASHCTRL_ACME_SUBDOMAIN=acme` |
| `CASHCTRL_<NAME>_API_KEY` | per organisation, the API user's key (Settings → Users & Roles) |
| `CASHCTRL_DEMO_SUBDOMAIN`, `CASHCTRL_DEMO_API_KEY` | the reserved organisation `demo`, used by this repository's tests; never listed in `CASHCTRL_ORGS` |
Every task and tool takes `org`, the name of one declared organisation, as its first
parameter, and every result names it as `meta.tenant`. `list_organisations()` (the tool
`cashctrl_list_organisations`) returns the declared names, nothing else. Each
organisation gets its own client, key and rate limit (60 requests per minute,
self-imposed); nothing spans organisations. A deployment reaches exactly the
organisations its environment declares. An unknown name is refused with the declared
ones; a malformed `CASHCTRL_ORGS` or a missing or malformed subdomain is a configuration
error naming the variable, never its value.
## 30-second example
```python
from xippios.cashctrl import tasks
from xippios.cashctrl.models import Filter
print(tasks.list_organisations().data) # the declared organisations, e.g. ["acme", "demo"]
# "demo" stands for any declared organisation; the first argument is always org.
periods = tasks.list_fiscal_periods("demo")
current = next(period for period in periods.data if period.is_current)
accounts = tasks.list_accounts("demo", fiscal_period_id=current.id, only_active=True)
for account in accounts.data:
print(account.number, account.name, account.end_amount) # a Decimal, or None
bank = tasks.list_accounts("demo", filter=[Filter(comparison="eq", field="number", value="1020")])
record = tasks.get_account("demo", bank.data[0].id)
print(record.meta.endpoint, record.meta.request, record.meta.retrieved_at)
# The journal, persons and orders page by offset: limit gives one page, and meta.total
# says how many match; without limit every page is collected.
page = tasks.list_journal_entries("demo", fiscal_period_id=current.id, limit=20)
print(len(page.data), "of", page.meta.total)
entry = tasks.get_journal_entry("demo", page.data[0].id) # with lines and bookings
invoices = tasks.list_orders("demo", type="SALES", fiscal_period_id=current.id)
```
Every task returns `Result[T]`: `data` holds models that mirror CashCtrl's payloads
under upstream's names in snake_case (`is_current`, `end_amount`), in Python and in every
tool and route result alike, `meta` the upstream operation, the parameters as sent under
upstream's names (`onlyActive`), the organisation as `tenant`, when they were fetched
and, for a list, `total`, upstream's count of the matching records. Async twins are
named `<task>_async` (`list_organisations` has none). The tasks are listed in the status
table below.
## MCP
```json
{
"mcpServers": {
"cashctrl": {
"command": "uvx",
"args": ["--from", "xippios-cashctrl[mcp]", "xippios-cashctrl-mcp", "--org", "acme"],
"env": {
"CASHCTRL_ORGS": "acme",
"CASHCTRL_ACME_SUBDOMAIN": "acme",
"CASHCTRL_ACME_API_KEY": "..."
}
}
}
}
```
`--org acme` locks the server to one organisation: it stops at startup unless `acme` is
declared with a well-formed subdomain, its instructions name the organisation, and a
tool call that names another organisation or leaves `org` out is refused before it
reaches CashCtrl; `cashctrl_list_organisations` then lists `acme` only. Without
`--org`, a server serves every declared organisation and the agent picks one per call,
after `cashctrl_list_organisations` has named them. Its tools are read-only and named
`cashctrl_<verb>_<noun>`. Results are capped at 200 rows or 64 KB with a note on how to
narrow the query. `--http` serves streamable HTTP instead of stdio.
Write tools run in a separate server, `xippios-cashctrl-mcp-write` (see Write tools
below); every write tool previews its change unless called with `confirm=true`. It
takes `--org` too. It carries no authentication, so `--http` serves it on a loopback
`--host` (`127.0.0.1`, `localhost`, `::1`) only; `--allow-remote` serves it beyond,
behind a gateway that authenticates.
```json
{
"mcpServers": {
"cashctrl-write": {
"command": "uvx",
"args": ["--from", "xippios-cashctrl[mcp]", "xippios-cashctrl-mcp-write", "--org", "acme"],
"env": {
"CASHCTRL_ORGS": "acme",
"CASHCTRL_ACME_SUBDOMAIN": "acme",
"CASHCTRL_ACME_API_KEY": "..."
}
}
}
}
```
## Write tools
Eight tools create, change and delete journal entries, persons and orders. They are
tasks in `xippios.cashctrl.tasks_write` (re-exported from `xippios.cashctrl`), served
by the write server only: never by the read server, never as an HTTP route.
| Upstream operation | Task function | MCP tool |
| --- | --- | --- |
| `POST journal/create.json` (Create journal entry) | `create_journal_entry` | `cashctrl_create_journal_entry` |
| `POST journal/update.json` (Update journal entry) | `update_journal_entry` | `cashctrl_update_journal_entry` |
| `POST journal/delete.json` (Delete journal entries) | `delete_journal_entry` | `cashctrl_delete_journal_entry` |
| `POST person/create.json` (Create person) | `create_person` | `cashctrl_create_person` |
| `POST person/update.json` (Update person) | `update_person` | `cashctrl_update_person` |
| `POST person/delete.json` (Delete people) | `delete_person` | `cashctrl_delete_person` |
| `POST order/create.json` (Create order) | `create_order` | `cashctrl_create_order` |
| `POST order/delete.json` (Delete orders) | `delete_order` | `cashctrl_delete_order` |
- **The confirm gate.** Every write tool takes `confirm`, last, `false` by default.
Without it nothing is written: the result is a preview, `performed: false`, with
`endpoint`, `payload` (the exact form fields CashCtrl would receive, under upstream's
names: booleans `true`/`false`, dates `YYYY-MM-DD`, lists as JSON, custom fields as
XML), `before` (for an update or a delete, the record as it is now) and `changes`
(for an update). A create's preview sends no request; an update's or a delete's sends
one read and never a write. With `confirm=true` the write is sent and the result is
`performed: true` with `insert_id` (the new record's id; an update answers the
record's own) and upstream's `message`. Call once without `confirm`, show the
preview, then confirm. Only `confirm=True` writes: from Python anything else
(`"false"`, `1`) previews; over MCP the schema turns `"true"`, `"yes"`, `"1"`, `1`
into true and `"false"`, `"no"`, `"0"`, `0` into false, and refuses anything else.
In Python the result is `Result[WritePreview[<record>] | WriteResult]`; narrow it
with `isinstance` or `if result.data.performed:`.
- **Updates send the whole record.** CashCtrl empties every parameter an update leaves
out. `update_journal_entry` and `update_person` therefore read the record, apply the
parameters given, and send every parameter `update.json` documents, the rest taken
from the record. A parameter left out keeps its value; `clear` names parameters to
send empty (`clear=["tax_id", "notes"]`; a flag such as `is_vendor` is sent `false`),
and a parameter both given and cleared is refused, as is `sequence_number_id`, which
an update sends only when given. Refused with `InvalidRequestError` before anything is
written, unless the call gives or clears what is missing: a record that gives no value
for a mandatory parameter; one holding a list whose shape was never observed
(attachments, allocations, bank accounts, children, service periods, insurance
contracts, certificate values), an address with a `titleId` or a contact with a
`purpose` (keys the tables do not document); an insurance whose read shows no
insurance numbers or an employee whose read shows no `user_id` (the read leaves empty
fields out, and these were never seen come back); and a journal entry an order or a
salary run booked. A confirmed update reads the record again and recomputes the
payload, so it can differ from the preview if the record changed in between.
- **Deletes** take one id; the preview shows the record that would go.
`delete_journal_entry` reads the entry with `confirm=true` as well and refuses, with
`InvalidRequestError` before anything is deleted, an entry an order or a salary run
booked; an unknown id is `NotFoundError` there. A person or an order that no longer
exists, or an entry deleted between that read and the delete, is refused by upstream
(`RequestRejected`).
- **Custom fields**: `custom` maps upstream's names (`customField1`, `customField<ID>`)
to text, sent as `<values><customField1>...</customField1></values>`, values escaped.
An update keeps every stored value it is not given.
- **Amounts.** A journal `amount` with a `tax_id` is the gross amount, VAT included, and
order items' `unit_price` was read as gross too (observed on the demo organisation,
although the tax codes' calculation type reads NET; `docs/demo-org.md`).
- **Ids** come from the read tools: account ids from `cashctrl_list_accounts` (the
`id`, not the account number), tax codes from `cashctrl_list_tax_codes`, persons from
`cashctrl_list_persons`, order categories and their statuses from
`cashctrl_list_order_categories`, currencies from `cashctrl_list_currencies`; the
record to update or delete from `cashctrl_list_journal_entries`,
`cashctrl_list_persons` or `cashctrl_list_orders` (the `id`, not the `nr`). A journal
`associate_id` is a person's id; create a new person with `cashctrl_create_person`
first (upstream would create one from a name, which the tools do not send).
- CashCtrl checks every write and refuses with the fields it objects to, e.g.
`error: RequestRejected: CashCtrl rejected the request: debitId: This field cannot be
empty.`; a refused write changed nothing. A write that meets a server error or a
timeout is never repeated (ADR-0011): read back before sending it again.
```python
from datetime import date
from decimal import Decimal
from xippios.cashctrl import tasks_write
from xippios.cashctrl.models import WritePreview, WriteResult
entry = dict(
date_added=date(2026, 10, 24),
amount=Decimal("100.00"), # gross, VAT included, with a tax_id
debit_id=3, # account ids from list_accounts, not account numbers
credit_id=42,
tax_id=9, # from list_tax_codes
title="xippios-live README example", # marked, should it ever run against demo
)
preview = tasks_write.create_journal_entry("demo", **entry)
assert isinstance(preview.data, WritePreview)
print(preview.data.payload) # what CashCtrl would receive; nothing was written
done = tasks_write.create_journal_entry("demo", **entry, confirm=True)
assert isinstance(done.data, WriteResult)
print(done.data.insert_id) # the new entry's id
```
## HTTP
`xippios.cashctrl.http.router` is a FastAPI router the consumer mounts. It has no lock:
every route but `GET /cashctrl/list_organisations` takes `org`, and the consumer passes
it explicitly on each request. A deployment that must reach one organisation only
declares only that one. A task whose
parameters are all scalars is a `GET /cashctrl/<task>` with query parameters; the list
tasks that take a `filter`, a list (`list_accounts`, `list_journal_entries`,
`list_persons`, `list_orders`), are a `POST` with a JSON body keyed by parameter name.
The MCP entry points quiet httpx's request log; the router does not, so a consumer that
logs at INFO logs httpx's request lines, whose URLs carry the organisation's host (its
subdomain): set the `httpx` logger to WARNING.
## Status
| Upstream operation | Task function | MCP tool | Status |
| --- | --- | --- | --- |
| none: the declared organisations | `list_organisations` | `cashctrl_list_organisations` | available; names only, no request |
| `GET fiscalperiod/list.json` (List fiscal periods) | `list_fiscal_periods` | `cashctrl_list_fiscal_periods` | available |
| `GET currency/list.json` (List currencies) | `list_currencies` | `cashctrl_list_currencies` | available |
| `GET tax/list.json` (List tax codes) | `list_tax_codes` | `cashctrl_list_tax_codes` | available |
| `GET account/list.json` (List accounts) | `list_accounts` | `cashctrl_list_accounts` | available; HTTP route is `POST` |
| `GET account/read.json` (Read account) | `get_account` | `cashctrl_get_account` | available |
| `GET journal/list.json` (List journal entries) | `list_journal_entries` | `cashctrl_list_journal_entries` | available; paged; HTTP route is `POST` |
| `GET journal/read.json` (Read journal entry) | `get_journal_entry` | `cashctrl_get_journal_entry` | available |
| `GET person/list.json` (List people) | `list_persons` | `cashctrl_list_persons` | available; paged; HTTP route is `POST` |
| `GET person/read.json` (Read person) | `get_person` | `cashctrl_get_person` | available |
| `GET order/list.json` (List orders) | `list_orders` | `cashctrl_list_orders` | available; paged; HTTP route is `POST` |
| `GET order/read.json` (Read order) | `get_order` | `cashctrl_get_order` | available |
| `GET order/category/list.json` (List categories) | `list_order_categories` | `cashctrl_list_order_categories` | available |
| `POST journal/create.json`, `update.json`, `delete.json` | `create_journal_entry`, `update_journal_entry`, `delete_journal_entry` | `cashctrl_create_journal_entry`, ... | available on the write server only |
| `POST person/create.json`, `update.json`, `delete.json` | `create_person`, `update_person`, `delete_person` | `cashctrl_create_person`, ... | available on the write server only |
| `POST order/create.json`, `delete.json` | `create_order`, `delete_order` | `cashctrl_create_order`, `cashctrl_delete_order` | available on the write server only |
`lang` (`de`, `fr`, `it`, `en`), which upstream takes on every request and which matters
mostly for error messages and exports, is not exposed yet. `list_accounts` checks `dir`
(`ASC`, `DESC`) and each filter's `comparison` (`eq`, `like`, `gt`, `lt`) before
anything is sent, since upstream silently ignores values it does not know; both are
typed as these value sets, so the tool schema lists them. A filter is a `Filter` or,
from Python, a mapping with the same keys.
The paged lists (`list_journal_entries`, `list_persons`, `list_orders`) take `start`
(default 0) and `limit`. With `limit` they return one page of at most `limit` records
from `start`; without it, every record from `start` on, fetched 100 at a time (upstream's
documented page size, about 1 s per 100 at the self-imposed pace). `meta.total` is
upstream's count of every record the request matches. The next page starts at `start`
plus the number of records received: over MCP the result cap (200 rows or 64 KB) may
return fewer than `limit`, so never advance by `limit`. MCP agents should always pass a
`limit` of about 20. Offset pages over a list that changes meanwhile can repeat or skip
records. Every other list returns its whole answer in one request and reports
upstream's `total` too.
The journal and order lists answer for the API user's current fiscal period unless
`fiscal_period_id` is given; that period is server-side state that
`fiscalperiod/switch.json` changes, and `meta.request` does not show which period
answered, so pass `fiscal_period_id` (an id from `list_fiscal_periods`) for a
reproducible answer. Without `type` (`SALES`, `PURCHASE`), `list_orders` returns both;
`list_order_categories` lists `SALES` (upstream's default) unless given `PURCHASE`.
## Upstream terms and attribution
Data comes from CashCtrl (https://cashctrl.com) under its own terms of use;
this package adds no rights to it. Documentation snapshot: `docs/upstream/`.
## Licence
Apache-2.0. See [LICENSE](LICENSE).
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues