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.
@@ -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()