xippios-cashctrl-mcp
This server does not currently have a deployable release on Glama. Follow the repository's setup instructions to install and configure it for your MCP client, including any required credentials and local dependencies.
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 routerRelated 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 |
| the organisations, comma-separated, lower-case letters, digits and |
| per organisation, the subdomain: |
| per organisation, the API user's key (Settings → Users & Roles) |
| the reserved organisation |
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 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
The confirm gate. Every write tool takes
confirm, last,falseby default. Without it nothing is written: the result is a preview,performed: false, withendpoint,payload(the exact form fields CashCtrl would receive, under upstream's names: booleanstrue/false, datesYYYY-MM-DD, lists as JSON, custom fields as XML;ssnand anibanasREDACTED, as inmeta.requestafter a write),before(for an update or a delete, the record as it is now, as the read tools show it) andchanges(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. Withconfirm=truethe write is sent and the result isperformed: truewithinsert_id(the new record's id; an update answers the record's own) and upstream'smessage. Call once withoutconfirm, show the preview, then confirm. Onlyconfirm=Truewrites: from Python anything else ("false",1) previews; over MCP the schema turns"true","yes","1",1into true and"false","no","0",0into false, and refuses anything else. In Python the result isResult[WritePreview[<record>] | WriteResult]; narrow it withisinstanceorif result.data.performed:.Updates send the whole record. CashCtrl empties every parameter an update leaves out.
update_journal_entry,update_personandupdate_ordertherefore read the record, apply the parameters given, and send every parameterupdate.jsondocuments, 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, ...), anditemsgiven replace them whole; upstream stores the items it is sent anew, so their ids change (observed).clearnames parameters to send empty (clear=["tax_id", "notes"]; a flag such asis_vendororis_display_item_grossis sentfalse), and a parameter both given and cleared is refused, as issequence_number_id, which an update sends only when given. Refused withInvalidRequestErrorbefore 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 atitleId, a contact with apurposeor 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 nouser_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_entryreads the entry withconfirm=trueas well and refuses, withInvalidRequestErrorbefore anything is deleted, an entry an order or a salary run booked; an unknown id isNotFoundErrorthere. 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_statussets the statusstatus_idon the ordersidsnames;create_order_paymentcreates the payment of the ordersorder_idsnames, dateddate, foramountper order or, without it, every open amount. CashCtrl documents a payment as the payment order for e-banking, a pain.001 file (typePAIN, its default, orSEPA_PAIN) or a PDF (WIRE_PDF,CASH_PDF), which itsorder/payment/downloaddelivers 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.PAINneeds the sender's and the recipient's bank accounts and the sender's BIC and refuses a date in the past;CASH_PDFneeded none of them (observed on the demo organisation, 2026-10-29). A status id comes from thestatusof the orders' category incashctrl_list_order_categories.Custom fields:
custommaps upstream's names (customField1,customField<ID>, the ids fromcashctrl_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
amountwith atax_idis the gross amount, VAT included, and order items'unit_pricewas 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(theid, not the account number), tax codes fromcashctrl_list_tax_codes, persons fromcashctrl_list_persons, order categories and their statuses fromcashctrl_list_order_categories, currencies fromcashctrl_list_currencies, a person'scategory_idfromcashctrl_list_person_categories, itstitle_idfromcashctrl_list_person_titlesand an employee'slocation_idfromcashctrl_list_locations, an allocation'sto_cost_center_idfromcashctrl_list_cost_centers; the record to update or delete fromcashctrl_list_journal_entries,cashctrl_list_personsorcashctrl_list_orders(theid, not thenr). A journalassociate_idis a person's id; create a new person withcashctrl_create_personfirst (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 idFiles
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
limitrecords, 100 whenlimitis left out (the account export, like its list, takes nolimit): read the list task'smeta.totalfirst and pass alimitof at least that.Personal identifiers. Without
columns, the person export names the fieldslist_personsreturns as its columns, sossnandibanstay out of the file; a caller that names them incolumnsgets 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 |
|
| available; names only, no request |
|
|
| available |
|
|
| available |
|
|
| available |
|
|
| available; HTTP route is |
|
|
| available |
|
|
| available; paged; HTTP route is |
|
|
| available |
|
|
| available; paged; HTTP route is |
|
|
| available |
|
|
| available; paged; HTTP route is |
|
|
| available |
|
|
| available; layout, sender, recipient, payment references; without |
|
|
| available; one order's ( |
|
|
| available |
|
|
| available; one module ( |
|
|
| available |
|
|
| available |
|
|
| available; flat, linked by |
|
|
| available; HTTP route is |
|
|
| available; without |
|
|
| available |
|
|
| available; rows as upstream sends them; payroll and people reports refused |
|
|
| available; paged; HTTP route is |
|
| none, Python only | available; one PDF to |
|
| none, Python only | available; a file to |
|
| none, Python only | available; a file to |
|
| none, Python only | available; a file to |
|
| none, Python only | available; a file to |
|
|
| available on the write server only |
|
|
| available on the write server only |
|
|
| available on the write server only |
|
|
| available on the write server only |
|
|
| 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…
This server cannot be deployed
Maintenance
Related MCP Connectors
Read-only search of 2,189 AI agent tools (MCP servers, x402 APIs) with daily automated trust scores.
Search and read published GoVisit destination and local business data through 22 read-only tools.
A read-only verified record of agent-operable GTM tools: search, fetch, compare, track changes.
Ask your accounts-receivable portfolio anything. Read-only, scoped to your account.
Related MCP Servers
- AlicenseNot gradedqualityNot gradedmaintenanceProvides access to Swiss court decisions through the entscheidsuche.ch API, enabling search, retrieval, and analysis of legal documents across different cantons and courts using natural language queries.-
- AlicenseNot gradedqualityDmaintenanceProvides 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
- AlicenseNot gradedqualityDmaintenanceEnables 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
- AlicenseAqualityCmaintenanceEnables 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.4MIT