Skip to main content
Glama
xippios-cashctrl-mcp

xippios-cashctrl-mcp

by xippios

xippios-cashctrl

Typed Python client, MCP server and FastAPI routes for CashCtrl, part of the xippios SDK family. Names and semantics follow the upstream documentation; every response carries release metadata (source, endpoint, retrieved_at, published_at, vintage).

Install

uv add xippios-cashctrl            # client only
uv add "xippios-cashctrl[mcp]"     # + MCP server
uv add "xippios-cashctrl[http]"    # + FastAPI router

Related MCP server: OpenClaw/Codex Read-only MCP

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

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 (the report rows of get_report_data excepted, which come as upstream sends them, and the file tasks' Downloaded, which describes a file; Files below), 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. Persons come back without the personal identifiers ssn and iban (list_persons, get_person, the persons get_journal_entry names; ADR-0017), locations and an order's document without their bank account numbers (iban and qr_iban, list_locations; orgIban, orgQrIban and recipientIban, get_order_document), and report rows without any key named ssn or ending in iban (get_report_data, which refuses payroll and people reports outright). An API user that wrote or changed a record shows as API:REDACTED in created_by and last_updated_by, as in the fixtures; CashCtrl writes API: and the first four characters of its key there.

MCP

{
  "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 binds the server to one organisation: it stops at startup unless acme is declared with a well-formed subdomain, its instructions name the organisation, and every tool acts on acme and takes no org, so no call reaches another organisation; 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 (cashctrl_list_cost_centers and cashctrl_list_history name their filters, and a paged list says where to resume); cashctrl_list_custom_fields at 500 rows or 128 KB and cashctrl_get_report_data at 128 KB, since an agent needs them whole. --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.

{
  "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

Eleven tools create, change and delete journal entries, persons and orders, set the status of orders and create the payment of 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/update.json (Update order)

update_order

cashctrl_update_order

POST order/delete.json (Delete orders)

delete_order

cashctrl_delete_order

POST order/update_status.json (Update status)

update_order_status

cashctrl_update_order_status

POST order/payment/create.json (Create payment)

create_order_payment

cashctrl_create_order_payment

  • 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; ssn and an iban as REDACTED, as in meta.request after a write), before (for an update or a delete, the record as it is now, as the read tools show it) and changes (for an update). A create's preview sends no request, nor does a status's or a payment's; 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, update_person and update_order 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: an order's items, too, are sent back as stored, each under the keys the items table documents (account_id, name, unit_price, quantity, tax_id, ...), and items given replace them whole; upstream stores the items it is sent anew, so their ids change (observed). clear names parameters to send empty (clear=["tax_id", "notes"]; a flag such as is_vendor or is_display_item_gross 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 order item's allocations), an address with a titleId, a contact with a purpose or an order item with attachments or marked optional (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).

  • Statuses and payments. update_order_status sets the status status_id on the orders ids names; create_order_payment creates the payment of the orders order_ids names, dated date, for amount per order or, without it, every open amount. CashCtrl documents a payment as the payment order for e-banking, a pain.001 file (type PAIN, its default, or SEPA_PAIN) or a PDF (WIRE_PDF, CASH_PDF), which its order/payment/download delivers afterwards (not wrapped here). Both send what they are given, nothing read first. A payment creates no book entry, and CashCtrl documents no way to read, list or delete one. PAIN needs the sender's and the recipient's bank accounts and the sender's BIC and refuses a date in the past; CASH_PDF needed none of them (observed on the demo organisation, 2026-10-29). A status id comes from the status of the orders' category in cashctrl_list_order_categories.

  • Custom fields: custom maps upstream's names (customField1, customField<ID>, the ids from cashctrl_list_custom_fields) 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, a person's category_id from cashctrl_list_person_categories, its title_id from cashctrl_list_person_titles and an employee's location_id from cashctrl_list_locations, an allocation's to_cost_center_id from cashctrl_list_cost_centers; 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.

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

Files

Five tasks write a file instead of returning records: download_order_document, the documents of orders as one PDF, and the list exports export_journal_entries, export_accounts, export_persons and export_orders, each with format (csv, xlsx or pdf), the filters of the list it mirrors and columns. They are Python only: no MCP tool and no HTTP route serves them, since no tool hands an agent a file path (BULK_TASKS, ADR-0019). Each streams the file to dest, the path the caller gives; nothing is read into the result, kept in memory or cached, and data describes the file (path, bytes, content_type, filename, retrieved_at). A refusal raises and writes nothing, and a file already at dest stays as it was: RequestRejected for one CashCtrl sends as HTTP 200, NotFoundError for an unknown order id, which CashCtrl answers with HTTP 404.

  • Cut at 100. Upstream exports at most limit records, 100 when limit is left out (the account export, like its list, takes no limit): read the list task's meta.total first and pass a limit of at least that.

  • Personal identifiers. Without columns, the person export names the fields list_persons returns as its columns, so ssn and iban stay out of the file; a caller that names them in columns gets them, deliberately (ADR-0017). An order document is what its recipient gets: an invoice's QR-bill may carry the organisation's IBAN or QR-IBAN and the recipient's name and address. Where a file goes is the caller's choice.

from pathlib import Path

from xippios.cashctrl import tasks

count = tasks.list_journal_entries("demo", fiscal_period_id=2, limit=1).meta.total or 1
journal = tasks.export_journal_entries(
    "demo", "csv", Path("journal-2025.csv"), fiscal_period_id=2, limit=count
)
print(journal.data.path, journal.data.bytes, journal.data.content_type)
invoice = tasks.download_order_document("demo", [3], Path("RE-2026-103.pdf"))

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_cost_centers, list_journal_entries, list_persons, list_orders, list_history), 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/document/read.json (Read document)

get_order_document

cashctrl_get_order_document

available; layout, sender, recipient, payment references; without orgIban, orgQrIban and recipientIban; the PDF from download_order_document

GET order/bookentry/list.json (List book entries)

list_order_book_entries

cashctrl_list_order_book_entries

available; one order's (order_id, sent as id)

GET order/category/list.json (List categories)

list_order_categories

cashctrl_list_order_categories

available

GET customfield/list.json (List custom fields)

list_custom_fields

cashctrl_list_custom_fields

available; one module (type) a call

GET person/category/list.json (List categories)

list_person_categories

cashctrl_list_person_categories

available

GET person/title/list.json (List titles)

list_person_titles

cashctrl_list_person_titles

available

GET account/category/list.json (List categories)

list_account_categories

cashctrl_list_account_categories

available; flat, linked by parent_id

GET account/costcenter/list.json (List cost centers)

list_cost_centers

cashctrl_list_cost_centers

available; HTTP route is POST; balances assumed of the current fiscal period

GET location/list.json (List locations)

list_locations

cashctrl_list_locations

available; without iban and qr_iban

GET report/tree.json (Get tree of reports)

get_report_tree

cashctrl_get_report_tree

available

GET report/element/data.json (Read JSON data)

get_report_data

cashctrl_get_report_data

available; rows as upstream sends them; payroll and people reports refused

GET history/list.json (List history)

list_history

cashctrl_list_history

available; paged; HTTP route is POST

GET order/document/read.pdf (Download PDF)

download_order_document

none, Python only

available; one PDF to dest; an invoice's QR-bill may carry the organisation's IBAN or QR-IBAN and the recipient's address

GET journal/list.csv, list.xlsx, list.pdf (Export to CSV, Excel, PDF)

export_journal_entries

none, Python only

available; a file to dest; at most limit, upstream's default 100

GET account/list.csv, list.xlsx, list.pdf (Export to CSV, Excel, PDF)

export_accounts

none, Python only

available; a file to dest; every matching account

GET person/list.csv, list.xlsx, list.pdf (Export to CSV, Excel, PDF)

export_persons

none, Python only

available; a file to dest; at most limit, upstream's default 100; without ssn and iban unless columns names them

GET order/list.csv, list.xlsx, list.pdf (Export to CSV, Excel, PDF)

export_orders

none, Python only

available; a file to dest; at most limit, upstream's default 100

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, update.json, delete.json

create_order, update_order, delete_order

cashctrl_create_order, ...

available on the write server only

POST order/update_status.json (Update status)

update_order_status

cashctrl_update_order_status

available on the write server only

POST order/payment/create.json (Create payment)

create_order_payment

cashctrl_create_order_payment

available on the write server only; no book entry, no delete documented

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, list_history) 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.

list_order_book_entries lists one order's book entries (order_id, sent as upstream's id): the journal entries its items booked (type GENERATED) and those booked to it by hand, such as payments, each with its rows; an order in a status that books nothing has none. list_history is the audit log, newest first: who changed what and when (created_by, change_type, message), narrowed with order_id, person_id or statement_id; the history of a deleted order is no longer listed (observed).

get_report_tree lists the organisation's reports, collections of report elements; get_report_data reads one element (element_id, a balance sheet, a profit and loss statement, a VAT settlement, ...) for fiscal_period or for start_date to end_date. Giving the fiscal period and a date is refused before anything is sent: upstream derives the dates from the period and would ignore the ones given. The report comes back as upstream sends it (data, the rows, nested; properties, the columns shown; total), under upstream's camelCase names and with amounts as JSON numbers: the documentation gives no schema, and the rows differ by report type. Payroll and people reports, which carry personal data under undocumented names, are refused before their data is requested: every report whose type (as get_report_tree shows it) starts with SALARY_ or contains PERSON, or is SETTLEMENT, WITHHOLDING_TAX_SETTLEMENT, PEOPLE or ORG_CHART; WITHHOLDING_TAX_RATES, rates without persons, is read. The type comes from the organisation's report tree, read once per organisation and process and kept; a report the tree does not list is asked of upstream. Every key named ssn or ending in iban (qrIban, orgIban) is dropped from what comes back, in any letter case and at any depth. list_account_categories is the flat account/category/list.json, each category with its parent_id; account/category/tree.json nests the same categories with display fields only. list_custom_fields takes the module (type: JOURNAL, PERSON, ORDER, ...) and refuses any other value; list_cost_centers checks dir and the filters as list_accounts does.

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.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    Related MCP Connectors

    Related MCP Servers

    • A
      license
      Not graded
      quality
      D
      maintenance
      Provides read-only access to OpenClaw and Codex operational data like status, tasks, sessions, logs, and Git changes. Ensures safety via redaction, allowlists, and bounded outputs.
      MIT
    • A
      license
      Not graded
      quality
      D
      maintenance
      Enables agent clients to perform read-only regulatory retrieval through tools for searching the corpus, retrieving source text, tracing citations, listing authorities, and inspecting recent filings. It is designed to keep regulated actions and workspace changes behind human review gates.
      Apache 2.0
    • A
      license
      A
      quality
      C
      maintenance
      Enables AI agents to read-only inspect an OmniFocus database, retrieving exact tasks and projects, Inbox roots, complete project hierarchies, tags, dates, notes and workload or review overviews. All access is read-only and bounded, with no mutation, write tools or sync triggers.
      4
      MIT