camt-exceptions

Raise and resolve ISO 20022 exceptions and investigations — the messages banks exchange when a payment needs stopping, returning, or explaining.

Message

What it is

camt.056.001.12

FI to FI Payment Cancellation Request

camt.029.001.14

Resolution of Investigation

The pair

camt.056 asks: stop or return this payment. camt.029 answers: here is what happened to it. Together they are the formal record of an investigation, which is what a bank needs when a customer says the money went to the wrong place.

from camt_exceptions.server import generate_message, get_required_fields

record = {
    "assignment_id": "ASG-2026-0001",
    "assigner_agent_bic": "DEUTDEFF",
    "assignee_agent_bic": "NWBKGB2L",
    "creation_date_time": "2026-06-21T10:00:00",
}
result = generate_message("camt.056.001.12", record)

Two things to know before you call it

The two types do not require the same fields. camt.029 additionally needs a confirmation_code; camt.056 does not. Ask rather than assume:

get_required_fields("camt.029.001.14")["required_fields"]
# ['assignment_id', 'assigner_agent_bic', 'assignee_agent_bic',
#  'creation_date_time', 'confirmation_code']

generate_message returns an error dict; it does not raise.

result = generate_message(message_type, record)
if "error" in result:
    ...            # refused
xml = result["xml"]

A caller that does not check will treat a refusal as a message. This is easy to get wrong: the benchmark in this repository did exactly that on its first run, timing the rejection path for one of the two types and reporting the result as throughput.

Install

pip install camt-exceptions        # Python 3.10+
uvx camt-exceptions                # or run the MCP server without installing

Transports

camt-exceptions-mcp                                   # stdio (default)
camt-exceptions-mcp --transport streamable-http       # HTTP on 127.0.0.1:8000/mcp
camt-exceptions-mcp --transport sse --port 8001       # the older HTTP+SSE transport

Streamable HTTP serves both current protocol revisions (2026-07-28 stateless with server/discover, and 2025-11-25 with the initialize handshake) on one endpoint. The listener binds loopback unless told otherwise and carries no authentication; put it behind a gateway before binding a routable address. See ADR 0001 for the decision.

Tools

Tool

Returns

list_message_types

Both supported types with their names

get_required_fields

What a given type requires

generate_message

{"xml": ...} or {"error": ...}

validate_xml

{"is_valid": bool, "errors": [...]}

The build_investigation_message prompt walks a client through the four tools in order; the camt-exceptions://message-types and camt-exceptions://required-fields/{message_type} resources expose the catalogue to clients that read resources rather than call tools.

Performance

benches/bench_investigations.py.

E&I messages are small — a few hundred bytes — so there is no size axis worth sweeping. The cost is per call, which is how they arrive: an operations team chasing a failed batch raises one per payment.

        message type   bytes  generate ms  validate ms  refuse ms
     camt.056.001.12     703         0.25         0.25      0.001
     camt.029.001.14     568         0.25         0.25      0.001
  • About 0.25 ms per message. A queue of a few thousand investigations is a background job, not a capacity problem.

  • Validation costs about the same as generation (0.99x). Both touch the schema, so generating and then validating pays for it twice.

  • Refusal is essentially free (~0.001 ms) — the work is skipped rather than done and discarded, which is the right way round.

The first message in a process costs about 205 ms, against 0.32 ms for the second — a 640x difference, because the XSD compiles once and is then cached. A worker handling one investigation per invocation pays that every time; a long-lived one pays it once. Measured in a fresh interpreter, since timing it in-process reports the warm number.

Worked examples

Both run standalone with no arguments and no network:

Quality gates

Every change passes a 100% line and branch coverage gate, a 100% docstring gate (interrogate), property-based tests (Hypothesis), a mutation-testing floor over the tool handlers and the generator (mutmut), ruff, black and strict mypy, and a benchmark that runs in CI so it cannot rot.

Licence

Apache-2.0 OR MIT, at your option.

Indices and tables