camt-exceptions 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- camt_exceptions/__init__.py +18 -0
- camt_exceptions/generator.py +171 -0
- camt_exceptions/server.py +153 -0
- camt_exceptions/templates/camt.056.001.12/camt.056.001.12.xsd +1185 -0
- camt_exceptions/templates/camt.056.001.12/template.xml +67 -0
- camt_exceptions-0.0.1.dist-info/METADATA +154 -0
- camt_exceptions-0.0.1.dist-info/RECORD +10 -0
- camt_exceptions-0.0.1.dist-info/WHEEL +4 -0
- camt_exceptions-0.0.1.dist-info/entry_points.txt +3 -0
- camt_exceptions-0.0.1.dist-info/licenses/LICENSE +189 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Copyright (C) 2023-2026 Sebastien Rousseau.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
12
|
+
# implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
|
|
16
|
+
"""camt-exceptions: ISO 20022 Exceptions & Investigations message generation."""
|
|
17
|
+
|
|
18
|
+
__version__ = "0.0.1"
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# Copyright (C) 2023-2026 Sebastien Rousseau.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
12
|
+
# implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
|
|
16
|
+
"""Generate and validate ISO 20022 Exceptions & Investigations (E&I) messages.
|
|
17
|
+
|
|
18
|
+
This is the ``camt`` counterpart to the generation engines elsewhere in the
|
|
19
|
+
suite: each supported message type bundles its official XSD plus a Jinja2
|
|
20
|
+
``template.xml``; :func:`generate_message` renders a record into XML and
|
|
21
|
+
:func:`validate_xml` checks any XML against the bundled schema.
|
|
22
|
+
|
|
23
|
+
Currently supported:
|
|
24
|
+
|
|
25
|
+
* ``camt.056.001.12`` -- FI to FI Payment Cancellation Request (recall or
|
|
26
|
+
cancel a previously sent payment, e.g. a duplicate or erroneous transfer).
|
|
27
|
+
|
|
28
|
+
Additional E&I messages (camt.029 resolution of investigation, camt.026 unable
|
|
29
|
+
to apply, camt.027 claim non-receipt, camt.087 request to modify) plug in by
|
|
30
|
+
dropping their XSD + ``template.xml`` alongside and registering them in
|
|
31
|
+
:data:`MESSAGE_TYPES`.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import functools
|
|
37
|
+
from importlib.resources import files
|
|
38
|
+
from typing import Any
|
|
39
|
+
|
|
40
|
+
import jinja2
|
|
41
|
+
import xmlschema
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class MessageSpec:
|
|
45
|
+
"""Static description of one supported E&I message type."""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
message_type: str,
|
|
50
|
+
name: str,
|
|
51
|
+
required: tuple[str, ...],
|
|
52
|
+
) -> None:
|
|
53
|
+
self.message_type = message_type
|
|
54
|
+
self.name = name
|
|
55
|
+
self.required = required
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# Registry of supported message types. Adding a message = bundle its
|
|
59
|
+
# templates/<mt>/<mt>.xsd + template.xml and add a MessageSpec here.
|
|
60
|
+
MESSAGE_TYPES: dict[str, MessageSpec] = {
|
|
61
|
+
"camt.056.001.12": MessageSpec(
|
|
62
|
+
message_type="camt.056.001.12",
|
|
63
|
+
name="FI to FI Payment Cancellation Request",
|
|
64
|
+
required=(
|
|
65
|
+
"assignment_id",
|
|
66
|
+
"assigner_agent_bic",
|
|
67
|
+
"assignee_agent_bic",
|
|
68
|
+
"creation_date_time",
|
|
69
|
+
),
|
|
70
|
+
),
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def list_message_types() -> list[dict[str, str]]:
|
|
75
|
+
"""Return every supported E&I message type and its human name."""
|
|
76
|
+
return [
|
|
77
|
+
{"message_type": s.message_type, "name": s.name}
|
|
78
|
+
for s in MESSAGE_TYPES.values()
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _spec(message_type: str) -> MessageSpec:
|
|
83
|
+
"""Return the spec for a message type or raise a helpful ValueError."""
|
|
84
|
+
spec = MESSAGE_TYPES.get(message_type)
|
|
85
|
+
if spec is None:
|
|
86
|
+
known = ", ".join(sorted(MESSAGE_TYPES))
|
|
87
|
+
raise ValueError(
|
|
88
|
+
f"unsupported message type {message_type!r}; supported: {known}"
|
|
89
|
+
)
|
|
90
|
+
return spec
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def get_required_fields(message_type: str) -> list[str]:
|
|
94
|
+
"""Return the required top-level fields for a message type."""
|
|
95
|
+
return list(_spec(message_type).required)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _template_text(message_type: str) -> str:
|
|
99
|
+
"""Read the bundled Jinja template for a message type."""
|
|
100
|
+
base = files("camt_exceptions") / "templates" / message_type
|
|
101
|
+
return (base / "template.xml").read_text(encoding="utf-8")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@functools.cache
|
|
105
|
+
def _schema(message_type: str) -> xmlschema.XMLSchema:
|
|
106
|
+
"""Load (and cache) the bundled XSD for a message type."""
|
|
107
|
+
base = files("camt_exceptions") / "templates" / message_type
|
|
108
|
+
xsd = base / f"{message_type}.xsd"
|
|
109
|
+
return xmlschema.XMLSchema(str(xsd))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@functools.cache
|
|
113
|
+
def _template(message_type: str) -> jinja2.Template:
|
|
114
|
+
"""Compile (and cache) the Jinja template for a message type."""
|
|
115
|
+
env = jinja2.Environment(
|
|
116
|
+
trim_blocks=True,
|
|
117
|
+
lstrip_blocks=True,
|
|
118
|
+
keep_trailing_newline=True,
|
|
119
|
+
autoescape=True,
|
|
120
|
+
)
|
|
121
|
+
return env.from_string(_template_text(message_type))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def generate_message(message_type: str, record: dict[str, Any]) -> str:
|
|
125
|
+
"""Render a validated E&I message to XML.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
message_type: e.g. ``"camt.056.001.12"``.
|
|
129
|
+
record: the message fields (see :func:`get_required_fields` for the
|
|
130
|
+
required keys; ``transactions`` is a list of per-transaction dicts).
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
The rendered ISO 20022 XML as a string.
|
|
134
|
+
|
|
135
|
+
Raises:
|
|
136
|
+
ValueError: unknown message type, a missing required field, or output
|
|
137
|
+
that fails XSD validation.
|
|
138
|
+
"""
|
|
139
|
+
spec = _spec(message_type)
|
|
140
|
+
missing = [f for f in spec.required if not record.get(f)]
|
|
141
|
+
if missing:
|
|
142
|
+
raise ValueError(
|
|
143
|
+
f"{message_type} is missing required field(s): "
|
|
144
|
+
f"{', '.join(missing)}"
|
|
145
|
+
)
|
|
146
|
+
xml = _template(message_type).render(**record)
|
|
147
|
+
errors = list(_schema(message_type).iter_errors(xml))
|
|
148
|
+
if errors:
|
|
149
|
+
first = str(errors[0]).splitlines()[0]
|
|
150
|
+
raise ValueError(
|
|
151
|
+
f"generated {message_type} failed XSD validation: {first}"
|
|
152
|
+
)
|
|
153
|
+
return xml
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def validate_xml(message_type: str, xml: str) -> dict[str, Any]:
|
|
157
|
+
"""Validate raw XML against a message type's bundled XSD.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
``{"message_type": ..., "is_valid": bool, "errors": [...]}`` -- never
|
|
161
|
+
raises on a validation failure (only on an unknown message type).
|
|
162
|
+
"""
|
|
163
|
+
_spec(message_type)
|
|
164
|
+
errors = [
|
|
165
|
+
str(e).splitlines()[0] for e in _schema(message_type).iter_errors(xml)
|
|
166
|
+
]
|
|
167
|
+
return {
|
|
168
|
+
"message_type": message_type,
|
|
169
|
+
"is_valid": not errors,
|
|
170
|
+
"errors": errors,
|
|
171
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# Copyright (C) 2023-2026 Sebastien Rousseau.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
12
|
+
# implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
|
|
16
|
+
"""Model Context Protocol (MCP) server for ISO 20022 Exceptions & Investigations.
|
|
17
|
+
|
|
18
|
+
Exposes generation and validation of E&I ``camt`` messages -- starting with
|
|
19
|
+
``camt.056`` (FI to FI Payment Cancellation Request) -- as MCP tools. Each tool
|
|
20
|
+
is a thin wrapper over :mod:`camt_exceptions.generator`; tools return
|
|
21
|
+
JSON-serializable data and, on a :class:`ValueError`, return an
|
|
22
|
+
``{"error": ...}`` payload rather than raising.
|
|
23
|
+
|
|
24
|
+
Launching the server:
|
|
25
|
+
* As a console script::
|
|
26
|
+
|
|
27
|
+
camt-exceptions-mcp
|
|
28
|
+
|
|
29
|
+
* In an MCP client config (e.g. Claude Desktop)::
|
|
30
|
+
|
|
31
|
+
{
|
|
32
|
+
"mcpServers": {
|
|
33
|
+
"camt-exceptions": {
|
|
34
|
+
"command": "camt-exceptions-mcp"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
The server communicates over stdio (FastMCP's default transport).
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from typing import Annotated, Any
|
|
43
|
+
|
|
44
|
+
from mcp.server.fastmcp import FastMCP
|
|
45
|
+
from mcp.types import ToolAnnotations
|
|
46
|
+
from pydantic import Field
|
|
47
|
+
|
|
48
|
+
from camt_exceptions import __version__, generator
|
|
49
|
+
|
|
50
|
+
server = FastMCP("camt-exceptions")
|
|
51
|
+
# FastMCP does not expose a version kwarg; without this override the MCP SDK's
|
|
52
|
+
# own version leaks into serverInfo.version, breaking manifest/runtime
|
|
53
|
+
# coherence checks (e.g. Glama scoring).
|
|
54
|
+
server._mcp_server.version = __version__
|
|
55
|
+
|
|
56
|
+
# Every tool is a pure, side-effect-free reader: it computes solely from its
|
|
57
|
+
# arguments and the XSDs/templates bundled with this package. Nothing opens a
|
|
58
|
+
# caller-supplied path or reaches an external system.
|
|
59
|
+
_PURE_READ = ToolAnnotations(
|
|
60
|
+
readOnlyHint=True,
|
|
61
|
+
destructiveHint=False,
|
|
62
|
+
idempotentHint=True,
|
|
63
|
+
openWorldHint=False,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
_MT_DESC = (
|
|
67
|
+
"An E&I message type, e.g. 'camt.056.001.12' (see list_message_types)."
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@server.tool(
|
|
72
|
+
annotations=_PURE_READ,
|
|
73
|
+
description=(
|
|
74
|
+
"List the supported ISO 20022 Exceptions & Investigations message "
|
|
75
|
+
"types (e.g. camt.056 payment cancellation request) and their names."
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
def list_message_types() -> dict[str, Any]:
|
|
79
|
+
"""List supported E&I message types."""
|
|
80
|
+
return {"message_types": generator.list_message_types()}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@server.tool(
|
|
84
|
+
annotations=_PURE_READ,
|
|
85
|
+
description=(
|
|
86
|
+
"Return the required top-level fields for an E&I message type."
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
def get_required_fields(
|
|
90
|
+
message_type: Annotated[str, Field(description=_MT_DESC)],
|
|
91
|
+
) -> dict[str, Any]:
|
|
92
|
+
"""Return the required fields for a message type."""
|
|
93
|
+
try:
|
|
94
|
+
return {
|
|
95
|
+
"message_type": message_type,
|
|
96
|
+
"required_fields": generator.get_required_fields(message_type),
|
|
97
|
+
}
|
|
98
|
+
except ValueError as exc:
|
|
99
|
+
return {"error": str(exc)}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@server.tool(
|
|
103
|
+
annotations=_PURE_READ,
|
|
104
|
+
description=(
|
|
105
|
+
"Generate a validated ISO 20022 E&I XML message from a record. For "
|
|
106
|
+
"camt.056, the record cancels/recalls a previously sent payment "
|
|
107
|
+
"(assignment ids + agent BICs + a list of 'transactions' with the "
|
|
108
|
+
"original payment references and a cancellation reason code). Output "
|
|
109
|
+
"is validated against the bundled XSD before it is returned."
|
|
110
|
+
),
|
|
111
|
+
)
|
|
112
|
+
def generate_message(
|
|
113
|
+
message_type: Annotated[str, Field(description=_MT_DESC)],
|
|
114
|
+
record: Annotated[
|
|
115
|
+
dict[str, Any],
|
|
116
|
+
Field(description="Message fields; see get_required_fields."),
|
|
117
|
+
],
|
|
118
|
+
) -> dict[str, Any]:
|
|
119
|
+
"""Generate a validated E&I XML message."""
|
|
120
|
+
try:
|
|
121
|
+
return {
|
|
122
|
+
"message_type": message_type,
|
|
123
|
+
"xml": generator.generate_message(message_type, record),
|
|
124
|
+
}
|
|
125
|
+
except ValueError as exc:
|
|
126
|
+
return {"error": str(exc)}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@server.tool(
|
|
130
|
+
annotations=_PURE_READ,
|
|
131
|
+
description=(
|
|
132
|
+
"Validate raw ISO 20022 XML against an E&I message type's bundled XSD; "
|
|
133
|
+
"returns is_valid plus any schema errors."
|
|
134
|
+
),
|
|
135
|
+
)
|
|
136
|
+
def validate_xml(
|
|
137
|
+
message_type: Annotated[str, Field(description=_MT_DESC)],
|
|
138
|
+
xml: Annotated[str, Field(description="Raw ISO 20022 XML to validate.")],
|
|
139
|
+
) -> dict[str, Any]:
|
|
140
|
+
"""Validate XML against a message type's XSD."""
|
|
141
|
+
try:
|
|
142
|
+
return generator.validate_xml(message_type, xml)
|
|
143
|
+
except ValueError as exc:
|
|
144
|
+
return {"error": str(exc)}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def main() -> None:
|
|
148
|
+
"""Run the E&I MCP server over stdio (the ``camt-exceptions-mcp`` entry)."""
|
|
149
|
+
server.run()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
if __name__ == "__main__":
|
|
153
|
+
main()
|