norma43-mcp 0.1.0__tar.gz
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.
- norma43_mcp-0.1.0/LICENSE +70 -0
- norma43_mcp-0.1.0/PKG-INFO +113 -0
- norma43_mcp-0.1.0/README.md +94 -0
- norma43_mcp-0.1.0/pyproject.toml +36 -0
- norma43_mcp-0.1.0/setup.cfg +4 -0
- norma43_mcp-0.1.0/src/norma43_mcp/__init__.py +3 -0
- norma43_mcp-0.1.0/src/norma43_mcp/__main__.py +3 -0
- norma43_mcp-0.1.0/src/norma43_mcp/billing.py +167 -0
- norma43_mcp-0.1.0/src/norma43_mcp/ids.py +236 -0
- norma43_mcp-0.1.0/src/norma43_mcp/n43.py +510 -0
- norma43_mcp-0.1.0/src/norma43_mcp/reconcile.py +493 -0
- norma43_mcp-0.1.0/src/norma43_mcp/server.py +235 -0
- norma43_mcp-0.1.0/src/norma43_mcp.egg-info/PKG-INFO +113 -0
- norma43_mcp-0.1.0/src/norma43_mcp.egg-info/SOURCES.txt +20 -0
- norma43_mcp-0.1.0/src/norma43_mcp.egg-info/dependency_links.txt +1 -0
- norma43_mcp-0.1.0/src/norma43_mcp.egg-info/entry_points.txt +2 -0
- norma43_mcp-0.1.0/src/norma43_mcp.egg-info/requires.txt +6 -0
- norma43_mcp-0.1.0/src/norma43_mcp.egg-info/top_level.txt +1 -0
- norma43_mcp-0.1.0/tests/test_core.py +241 -0
- norma43_mcp-0.1.0/tests/test_defaults.py +64 -0
- norma43_mcp-0.1.0/tests/test_e2e.py +165 -0
- norma43_mcp-0.1.0/tests/test_limits.py +54 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
License text copyright (c) 2020 MariaDB Corporation Ab, All Rights Reserved.
|
|
2
|
+
"Business Source License" is a trademark of MariaDB Corporation Ab.
|
|
3
|
+
|
|
4
|
+
Parameters
|
|
5
|
+
|
|
6
|
+
Licensor: ArceeDev (KeyFlint, https://keyflint.dev, legal@keyflint.dev)
|
|
7
|
+
Licensed Work: Norma 43 MCP (norma43-mcp), version 0.1.0 or later.
|
|
8
|
+
The Licensed Work is (c) 2026 ArceeDev.
|
|
9
|
+
Additional Use Grant: You may make production use of the Licensed Work:
|
|
10
|
+
(a) with a valid KeyFlint license for the Licensed Work
|
|
11
|
+
obtained from the Licensor (https://mcp.keyflint.dev),
|
|
12
|
+
with tool calls metered through the Licensor's
|
|
13
|
+
KeyFlint gateway; or
|
|
14
|
+
(b) for personal, educational, research or evaluation
|
|
15
|
+
purposes that are not commercial.
|
|
16
|
+
This grant does not cover removing, disabling or
|
|
17
|
+
bypassing the license verification or credit metering,
|
|
18
|
+
nor offering the Licensed Work, or a modified version of
|
|
19
|
+
it, to third parties on a hosted, embedded or resold
|
|
20
|
+
basis.
|
|
21
|
+
Change Date: 2030-11-05
|
|
22
|
+
Change License: Apache License, Version 2.0
|
|
23
|
+
|
|
24
|
+
For information about alternative licensing arrangements for the Licensed Work,
|
|
25
|
+
please contact the Licensor at legal@keyflint.dev or https://keyflint.dev.
|
|
26
|
+
|
|
27
|
+
Notice
|
|
28
|
+
|
|
29
|
+
Business Source License 1.1
|
|
30
|
+
|
|
31
|
+
Terms
|
|
32
|
+
|
|
33
|
+
The Licensor hereby grants you the right to copy, modify, create derivative
|
|
34
|
+
works, redistribute, and make non-production use of the Licensed Work. The
|
|
35
|
+
Licensor may make an Additional Use Grant, above, permitting limited production use.
|
|
36
|
+
|
|
37
|
+
Effective on the Change Date, or the fourth anniversary of the first publicly
|
|
38
|
+
available distribution of a specific version of the Licensed Work under this
|
|
39
|
+
License, whichever comes first, the Licensor hereby grants you rights under
|
|
40
|
+
the terms of the Change License, and the rights granted in the paragraph
|
|
41
|
+
above terminate.
|
|
42
|
+
|
|
43
|
+
If your use of the Licensed Work does not comply with the requirements
|
|
44
|
+
currently in effect as described in this License, you must purchase a
|
|
45
|
+
commercial license from the Licensor, its affiliated entities, or authorized
|
|
46
|
+
resellers, or you must refrain from using the Licensed Work.
|
|
47
|
+
|
|
48
|
+
All copies of the original and modified Licensed Work, and derivative works
|
|
49
|
+
of the Licensed Work, are subject to this License. This License applies
|
|
50
|
+
separately for each version of the Licensed Work and the Change Date may vary
|
|
51
|
+
for each version of the Licensed Work released by Licensor.
|
|
52
|
+
|
|
53
|
+
You must conspicuously display this License on each original or modified copy
|
|
54
|
+
of the Licensed Work. If you receive the Licensed Work in original or
|
|
55
|
+
modified form from a third party, the terms and conditions set forth in this
|
|
56
|
+
License apply to your use of that work.
|
|
57
|
+
|
|
58
|
+
Any use of the Licensed Work in violation of this License will automatically
|
|
59
|
+
terminate your rights under this License for the current and all other
|
|
60
|
+
versions of the Licensed Work.
|
|
61
|
+
|
|
62
|
+
This License does not grant you any right in any trademark or logo of
|
|
63
|
+
Licensor or its affiliates (provided that you may use a trademark or logo of
|
|
64
|
+
Licensor as expressly required by this License).
|
|
65
|
+
|
|
66
|
+
TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
|
|
67
|
+
AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
|
|
68
|
+
EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
|
|
69
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
|
|
70
|
+
TITLE.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: norma43-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for Spanish bank statements (Norma 43 / Cuaderno 43) and bank reconciliation. Billed per call with KeyFlint credits.
|
|
5
|
+
Author: ArceeDev
|
|
6
|
+
License-Expression: BUSL-1.1
|
|
7
|
+
Project-URL: Homepage, https://github.com/ArceeDev/norma43-mcp
|
|
8
|
+
Project-URL: Source, https://github.com/ArceeDev/norma43-mcp
|
|
9
|
+
Keywords: mcp,norma43,cuaderno43,aeb43,conciliacion-bancaria,bank-reconciliation,sepa,iban,spain
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Requires-Dist: mcp>=2.0
|
|
14
|
+
Requires-Dist: keyflint-verify[fast]>=1.2.0
|
|
15
|
+
Provides-Extra: test
|
|
16
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
17
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "test"
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# norma43-mcp
|
|
21
|
+
|
|
22
|
+
MCP para **extractos bancarios españoles Norma 43** (Cuaderno 43 / AEB43 / CSB43) y **conciliación bancaria** para agentes de IA. Cobro por créditos con [KeyFlint](https://keyflint.com).
|
|
23
|
+
|
|
24
|
+
Todos los bancos españoles exportan Norma 43; gestorías y departamentos financieros concilian cada mes. Este servidor lee el fichero como lo valida un banco y cruza movimientos con facturas sin adivinar nunca.
|
|
25
|
+
|
|
26
|
+
## Herramientas
|
|
27
|
+
|
|
28
|
+
| Tool | Créditos | Qué hace |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| `parse_norma43` | 1 | Cuentas, IBAN calculado, banco, titular, saldos, movimientos, textos 23 y campos SEPA (modalidad 3). Comprueba totales del registro 33, saldo inicial + movimientos = final, recuento del 88, fechas, signos y formatos. |
|
|
31
|
+
| `norma43_to_csv` | 1 | CSV listo para Excel español (`;`, coma decimal), protegido contra inyección de fórmulas. |
|
|
32
|
+
| `reconcile_movements` | 3 | Conciliación 1:1, 1:N (una transferencia paga varias facturas) y N:1 (pagos parciales). Puntuación 0-100, confianza y evidencias. Empates → `ambiguous`. |
|
|
33
|
+
| `validate_identifier` | 0 | IBAN, CCC, NIF/NIE/CIF, identificador de acreedor SEPA, Referencia 1. |
|
|
34
|
+
| `sepa_creditor_id` | 0 | Identificador de acreedor SEPA (AT-02) a partir del NIF. |
|
|
35
|
+
| `pricing`, `credits_balance` | 0 | Precios y saldo de la licencia. |
|
|
36
|
+
|
|
37
|
+
Precios configurables: `NORMA43_PRICES="parse_norma43=2,reconcile_movements=5"`.
|
|
38
|
+
|
|
39
|
+
Importes: cadenas con 2 decimales; negativo = cargo (sale dinero), positivo = abono (entra).
|
|
40
|
+
|
|
41
|
+
## Instalación
|
|
42
|
+
|
|
43
|
+
Solo necesitas tu licencia KeyFlint (`LIC-...`). La URL del gateway, su clave pública y el enlace de compra vienen por defecto.
|
|
44
|
+
|
|
45
|
+
**Claude Desktop, 1 clic:** descarga `norma43-mcp-X.Y.Z.mcpb` de la [última release](https://github.com/ArceeDev/norma43-mcp/releases/latest), ábrelo con doble clic y pega tu licencia. Sin terminal ni JSON.
|
|
46
|
+
|
|
47
|
+
**Claude Code:**
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
claude mcp add norma43 -e KEYFLINT_LICENSE=LIC-... -- uvx norma43-mcp
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
**Cursor, Windsurf, Claude Desktop (JSON):**
|
|
54
|
+
|
|
55
|
+
```json
|
|
56
|
+
{
|
|
57
|
+
"mcpServers": {
|
|
58
|
+
"norma43": {
|
|
59
|
+
"command": "uvx",
|
|
60
|
+
"args": ["norma43-mcp"],
|
|
61
|
+
"env": { "KEYFLINT_LICENSE": "LIC-..." }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Opcionales: `KEYFLINT_URL`, `KEYFLINT_PUBLIC_KEYS` (si no se pone, se descarga del gateway y se guarda en `~/.keyflint/keyring.json`), `KEYFLINT_CHECKOUT_URL`.
|
|
68
|
+
|
|
69
|
+
## Modo hosted (HTTP, licencia por petición)
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
docker build -t norma43-mcp .
|
|
73
|
+
docker run -p 8080:8080 \
|
|
74
|
+
norma43-mcp
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
El cliente envía `Authorization: Bearer LIC-...` (o `X-License-Key`).
|
|
78
|
+
|
|
79
|
+
Variables: `NORMA43_TRANSPORT` (`stdio`|`http`), `NORMA43_PRICES`, `NORMA43_MAX_BYTES` (por defecto 5 MB), `PORT`, `HOST`.
|
|
80
|
+
|
|
81
|
+
## Flujo de cobro por llamada
|
|
82
|
+
|
|
83
|
+
1. Se decodifica y valida la entrada. **Entrada ilegible u opciones inválidas: no se cobra.**
|
|
84
|
+
2. Se verifica la licencia (Ed25519, offline) antes de gastar CPU.
|
|
85
|
+
3. Se parsea el fichero.
|
|
86
|
+
4. Se descuentan los créditos (idempotente).
|
|
87
|
+
5. Si la herramienta falla después del cobro, **se reembolsa**.
|
|
88
|
+
6. Sin créditos: error `-32062` con la URL de compra.
|
|
89
|
+
|
|
90
|
+
## Reglas de conciliación
|
|
91
|
+
|
|
92
|
+
- Céntimos enteros, sin coma flotante.
|
|
93
|
+
- Evidencias: importe exacto (o dentro de `amount_tolerance`), referencia de factura en el concepto bancario, nombre de la contraparte, distancia de fechas y unicidad.
|
|
94
|
+
- Una referencia solo cuenta con ≥4 caracteres; las numéricas, con ≥6 dígitos y límites de dígito.
|
|
95
|
+
- Agrupaciones (1:N, N:1) solo si la suma exacta es única y hay evidencia de referencia o nombre; si no, van a `suggestions`.
|
|
96
|
+
- Dos candidatos con la misma puntuación nunca se resuelven: van a `ambiguous` para revisión humana.
|
|
97
|
+
|
|
98
|
+
## Limitaciones
|
|
99
|
+
|
|
100
|
+
- La interpretación SEPA de los registros 23 (Anexo 4) solo se aplica en modalidad 3; en las demás se devuelve el texto tal cual.
|
|
101
|
+
- El nombre del banco es informativo (tabla de las entidades principales).
|
|
102
|
+
- Validado contra la especificación oficial (AEB/CECA/UNACC, junio 2012) y sus ejemplos, con ficheros generados por un escritor independiente; conviene probarlo con extractos reales de cada banco.
|
|
103
|
+
|
|
104
|
+
## Tests
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
pip install -e ".[test]"
|
|
108
|
+
python -m pytest -q
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Licencia
|
|
112
|
+
|
|
113
|
+
Business Source License 1.1. Código visible; el uso en producción requiere una licencia KeyFlint (o ser no comercial). Pasa a Apache 2.0 cuatro años después de cada versión. Ver [LICENSE](LICENSE).
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# norma43-mcp
|
|
2
|
+
|
|
3
|
+
MCP para **extractos bancarios españoles Norma 43** (Cuaderno 43 / AEB43 / CSB43) y **conciliación bancaria** para agentes de IA. Cobro por créditos con [KeyFlint](https://keyflint.com).
|
|
4
|
+
|
|
5
|
+
Todos los bancos españoles exportan Norma 43; gestorías y departamentos financieros concilian cada mes. Este servidor lee el fichero como lo valida un banco y cruza movimientos con facturas sin adivinar nunca.
|
|
6
|
+
|
|
7
|
+
## Herramientas
|
|
8
|
+
|
|
9
|
+
| Tool | Créditos | Qué hace |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| `parse_norma43` | 1 | Cuentas, IBAN calculado, banco, titular, saldos, movimientos, textos 23 y campos SEPA (modalidad 3). Comprueba totales del registro 33, saldo inicial + movimientos = final, recuento del 88, fechas, signos y formatos. |
|
|
12
|
+
| `norma43_to_csv` | 1 | CSV listo para Excel español (`;`, coma decimal), protegido contra inyección de fórmulas. |
|
|
13
|
+
| `reconcile_movements` | 3 | Conciliación 1:1, 1:N (una transferencia paga varias facturas) y N:1 (pagos parciales). Puntuación 0-100, confianza y evidencias. Empates → `ambiguous`. |
|
|
14
|
+
| `validate_identifier` | 0 | IBAN, CCC, NIF/NIE/CIF, identificador de acreedor SEPA, Referencia 1. |
|
|
15
|
+
| `sepa_creditor_id` | 0 | Identificador de acreedor SEPA (AT-02) a partir del NIF. |
|
|
16
|
+
| `pricing`, `credits_balance` | 0 | Precios y saldo de la licencia. |
|
|
17
|
+
|
|
18
|
+
Precios configurables: `NORMA43_PRICES="parse_norma43=2,reconcile_movements=5"`.
|
|
19
|
+
|
|
20
|
+
Importes: cadenas con 2 decimales; negativo = cargo (sale dinero), positivo = abono (entra).
|
|
21
|
+
|
|
22
|
+
## Instalación
|
|
23
|
+
|
|
24
|
+
Solo necesitas tu licencia KeyFlint (`LIC-...`). La URL del gateway, su clave pública y el enlace de compra vienen por defecto.
|
|
25
|
+
|
|
26
|
+
**Claude Desktop, 1 clic:** descarga `norma43-mcp-X.Y.Z.mcpb` de la [última release](https://github.com/ArceeDev/norma43-mcp/releases/latest), ábrelo con doble clic y pega tu licencia. Sin terminal ni JSON.
|
|
27
|
+
|
|
28
|
+
**Claude Code:**
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
claude mcp add norma43 -e KEYFLINT_LICENSE=LIC-... -- uvx norma43-mcp
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
**Cursor, Windsurf, Claude Desktop (JSON):**
|
|
35
|
+
|
|
36
|
+
```json
|
|
37
|
+
{
|
|
38
|
+
"mcpServers": {
|
|
39
|
+
"norma43": {
|
|
40
|
+
"command": "uvx",
|
|
41
|
+
"args": ["norma43-mcp"],
|
|
42
|
+
"env": { "KEYFLINT_LICENSE": "LIC-..." }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Opcionales: `KEYFLINT_URL`, `KEYFLINT_PUBLIC_KEYS` (si no se pone, se descarga del gateway y se guarda en `~/.keyflint/keyring.json`), `KEYFLINT_CHECKOUT_URL`.
|
|
49
|
+
|
|
50
|
+
## Modo hosted (HTTP, licencia por petición)
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
docker build -t norma43-mcp .
|
|
54
|
+
docker run -p 8080:8080 \
|
|
55
|
+
norma43-mcp
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
El cliente envía `Authorization: Bearer LIC-...` (o `X-License-Key`).
|
|
59
|
+
|
|
60
|
+
Variables: `NORMA43_TRANSPORT` (`stdio`|`http`), `NORMA43_PRICES`, `NORMA43_MAX_BYTES` (por defecto 5 MB), `PORT`, `HOST`.
|
|
61
|
+
|
|
62
|
+
## Flujo de cobro por llamada
|
|
63
|
+
|
|
64
|
+
1. Se decodifica y valida la entrada. **Entrada ilegible u opciones inválidas: no se cobra.**
|
|
65
|
+
2. Se verifica la licencia (Ed25519, offline) antes de gastar CPU.
|
|
66
|
+
3. Se parsea el fichero.
|
|
67
|
+
4. Se descuentan los créditos (idempotente).
|
|
68
|
+
5. Si la herramienta falla después del cobro, **se reembolsa**.
|
|
69
|
+
6. Sin créditos: error `-32062` con la URL de compra.
|
|
70
|
+
|
|
71
|
+
## Reglas de conciliación
|
|
72
|
+
|
|
73
|
+
- Céntimos enteros, sin coma flotante.
|
|
74
|
+
- Evidencias: importe exacto (o dentro de `amount_tolerance`), referencia de factura en el concepto bancario, nombre de la contraparte, distancia de fechas y unicidad.
|
|
75
|
+
- Una referencia solo cuenta con ≥4 caracteres; las numéricas, con ≥6 dígitos y límites de dígito.
|
|
76
|
+
- Agrupaciones (1:N, N:1) solo si la suma exacta es única y hay evidencia de referencia o nombre; si no, van a `suggestions`.
|
|
77
|
+
- Dos candidatos con la misma puntuación nunca se resuelven: van a `ambiguous` para revisión humana.
|
|
78
|
+
|
|
79
|
+
## Limitaciones
|
|
80
|
+
|
|
81
|
+
- La interpretación SEPA de los registros 23 (Anexo 4) solo se aplica en modalidad 3; en las demás se devuelve el texto tal cual.
|
|
82
|
+
- El nombre del banco es informativo (tabla de las entidades principales).
|
|
83
|
+
- Validado contra la especificación oficial (AEB/CECA/UNACC, junio 2012) y sus ejemplos, con ficheros generados por un escritor independiente; conviene probarlo con extractos reales de cada banco.
|
|
84
|
+
|
|
85
|
+
## Tests
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
pip install -e ".[test]"
|
|
89
|
+
python -m pytest -q
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Licencia
|
|
93
|
+
|
|
94
|
+
Business Source License 1.1. Código visible; el uso en producción requiere una licencia KeyFlint (o ser no comercial). Pasa a Apache 2.0 cuatro años después de cada versión. Ver [LICENSE](LICENSE).
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "norma43-mcp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "MCP server for Spanish bank statements (Norma 43 / Cuaderno 43) and bank reconciliation. Billed per call with KeyFlint credits."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "BUSL-1.1"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "ArceeDev" }]
|
|
14
|
+
keywords = ["mcp", "norma43", "cuaderno43", "aeb43", "conciliacion-bancaria", "bank-reconciliation", "sepa", "iban", "spain"]
|
|
15
|
+
dependencies = [
|
|
16
|
+
"mcp>=2.0",
|
|
17
|
+
"keyflint-verify[fast]>=1.2.0",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.optional-dependencies]
|
|
21
|
+
test = ["pytest>=8", "pytest-asyncio>=0.23"]
|
|
22
|
+
|
|
23
|
+
[project.scripts]
|
|
24
|
+
norma43-mcp = "norma43_mcp.server:main"
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/ArceeDev/norma43-mcp"
|
|
28
|
+
Source = "https://github.com/ArceeDev/norma43-mcp"
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.packages.find]
|
|
31
|
+
where = ["src"]
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
asyncio_mode = "auto"
|
|
35
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
36
|
+
pythonpath = ["src", "tests"]
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Per-call credit billing on top of keyflint_verify.mcp.KeyFlint.
|
|
2
|
+
|
|
3
|
+
stdio: the license comes from KEYFLINT_LICENSE (one buyer per process).
|
|
4
|
+
HTTP: the license comes from each request (``Authorization: Bearer LIC-...`` or
|
|
5
|
+
``X-KeyFlint-License``), so one hosted server bills many buyers.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import os
|
|
12
|
+
import pathlib
|
|
13
|
+
import threading
|
|
14
|
+
import uuid
|
|
15
|
+
from collections import OrderedDict
|
|
16
|
+
from contextlib import asynccontextmanager
|
|
17
|
+
from typing import Any, AsyncIterator, Mapping, Optional
|
|
18
|
+
|
|
19
|
+
from keyflint_verify.mcp import LICENSE_INVALID, KeyFlint, LicenseError
|
|
20
|
+
|
|
21
|
+
LICENSE_HEADERS = ("x-license-key", "authorization")
|
|
22
|
+
_MAX_CLIENTS = 1024
|
|
23
|
+
|
|
24
|
+
DEFAULT_GATEWAY = "https://licenses.keyflint.dev"
|
|
25
|
+
DEFAULT_CHECKOUT = "https://mcp.keyflint.dev/precios/"
|
|
26
|
+
_KEYRING_TTL = 6 * 3600
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _env(name: str) -> str:
|
|
30
|
+
return (os.environ.get("KEYFLINT_" + name) or os.environ.get("KEYFORGE_" + name) or "").strip()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _fetch_keyring(gateway: str) -> str:
|
|
34
|
+
"""Gateway public keyring, cached in ~/.keyflint (stale copy used when offline)."""
|
|
35
|
+
import hashlib
|
|
36
|
+
import json
|
|
37
|
+
import time
|
|
38
|
+
import urllib.request
|
|
39
|
+
|
|
40
|
+
cache = pathlib.Path(os.environ.get("KEYFLINT_KEYRING_CACHE")
|
|
41
|
+
or pathlib.Path.home() / ".keyflint" / "keyring.json")
|
|
42
|
+
slot = hashlib.sha256(gateway.encode()).hexdigest()[:16]
|
|
43
|
+
try:
|
|
44
|
+
stored = json.loads(cache.read_text("utf-8"))
|
|
45
|
+
except (OSError, ValueError):
|
|
46
|
+
stored = {}
|
|
47
|
+
entry = stored.get(slot) or {}
|
|
48
|
+
if entry.get("keyring") and time.time() - float(entry.get("at", 0)) < _KEYRING_TTL:
|
|
49
|
+
return str(entry["keyring"])
|
|
50
|
+
try:
|
|
51
|
+
req = urllib.request.Request(gateway + "/api/v1/licenses/public-key",
|
|
52
|
+
headers={"Accept": "application/json"})
|
|
53
|
+
with urllib.request.urlopen(req, timeout=4) as resp: # noqa: S310 - https gateway
|
|
54
|
+
keyring = str(json.loads(resp.read().decode("utf-8"))["data"]["keyring"]).strip()
|
|
55
|
+
except Exception: # noqa: BLE001 - offline: fall back to the cached keyring
|
|
56
|
+
return str(entry.get("keyring") or "")
|
|
57
|
+
if keyring:
|
|
58
|
+
try:
|
|
59
|
+
stored[slot] = {"keyring": keyring, "at": time.time()}
|
|
60
|
+
cache.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
cache.write_text(json.dumps(stored), "utf-8")
|
|
62
|
+
except OSError:
|
|
63
|
+
pass
|
|
64
|
+
return keyring
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def apply_defaults() -> None:
|
|
68
|
+
"""Only KEYFLINT_LICENSE is required: gateway, keyring and checkout default to KeyFlint's."""
|
|
69
|
+
if not _env("URL"):
|
|
70
|
+
os.environ["KEYFLINT_URL"] = DEFAULT_GATEWAY
|
|
71
|
+
if not _env("CHECKOUT_URL"):
|
|
72
|
+
os.environ["KEYFLINT_CHECKOUT_URL"] = DEFAULT_CHECKOUT
|
|
73
|
+
if not (_env("PUBLIC_KEYS") or _env("PUBLIC_KEY")):
|
|
74
|
+
keyring = _fetch_keyring(_env("URL").rstrip("/"))
|
|
75
|
+
if keyring:
|
|
76
|
+
os.environ["KEYFLINT_PUBLIC_KEYS"] = keyring
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def license_from_headers(headers: Optional[Mapping[str, str]]) -> Optional[str]:
|
|
80
|
+
if not headers:
|
|
81
|
+
return None
|
|
82
|
+
lower = {k.lower(): v for k, v in headers.items()}
|
|
83
|
+
raw = lower.get("x-license-key") or ""
|
|
84
|
+
if not raw:
|
|
85
|
+
auth = lower.get("authorization") or ""
|
|
86
|
+
if auth[:7].lower() == "bearer ":
|
|
87
|
+
raw = auth[7:]
|
|
88
|
+
raw = raw.strip()
|
|
89
|
+
return raw or None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class Billing:
|
|
93
|
+
"""Resolves the buyer's license and debits credits around a tool body."""
|
|
94
|
+
|
|
95
|
+
def __init__(self, *, hosted: bool = False, **kf_kwargs: Any) -> None:
|
|
96
|
+
self.hosted = hosted
|
|
97
|
+
if "public_keys" not in kf_kwargs:
|
|
98
|
+
apply_defaults()
|
|
99
|
+
self._kf_kwargs = kf_kwargs
|
|
100
|
+
self._env_kf = KeyFlint(**kf_kwargs)
|
|
101
|
+
self._clients: "OrderedDict[str, KeyFlint]" = OrderedDict()
|
|
102
|
+
self._lock = threading.Lock()
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def checkout_url(self) -> Optional[str]:
|
|
106
|
+
return self._env_kf.checkout_url
|
|
107
|
+
|
|
108
|
+
def client(self, headers: Optional[Mapping[str, str]] = None) -> KeyFlint:
|
|
109
|
+
key = license_from_headers(headers)
|
|
110
|
+
if key is None:
|
|
111
|
+
if self.hosted:
|
|
112
|
+
text = "Send your KeyFlint license as 'Authorization: Bearer LIC-...'."
|
|
113
|
+
if self.checkout_url:
|
|
114
|
+
text += f" Get one: {self.checkout_url}"
|
|
115
|
+
raise LicenseError(LICENSE_INVALID, text, {"reason": "ERR_EMPTY_TOKEN",
|
|
116
|
+
"checkout_url": self.checkout_url})
|
|
117
|
+
return self._env_kf
|
|
118
|
+
with self._lock:
|
|
119
|
+
kf = self._clients.get(key)
|
|
120
|
+
if kf is None:
|
|
121
|
+
# Hosted: server clock is trusted, no node lock (the buyer's machine is not ours).
|
|
122
|
+
kf = KeyFlint(license_key=key, machine_id=None, clock_guard=False,
|
|
123
|
+
public_keys=self._env_kf.public_keys, gateway_url=self._env_kf.gateway_url,
|
|
124
|
+
checkout_url=self._env_kf.checkout_url, timeout=self._env_kf.timeout)
|
|
125
|
+
self._clients[key] = kf
|
|
126
|
+
if len(self._clients) > _MAX_CLIENTS:
|
|
127
|
+
self._clients.popitem(last=False)
|
|
128
|
+
else:
|
|
129
|
+
self._clients.move_to_end(key)
|
|
130
|
+
return kf
|
|
131
|
+
|
|
132
|
+
@asynccontextmanager
|
|
133
|
+
async def charge(self, credits: int, tool: str, headers: Optional[Mapping[str, str]] = None,
|
|
134
|
+
features: Optional[list[str]] = None) -> AsyncIterator[dict[str, Any]]:
|
|
135
|
+
"""Offline license check, debit, run body; refund if the body raises."""
|
|
136
|
+
kf = self.client(headers)
|
|
137
|
+
kf.check(features)
|
|
138
|
+
idem = f"n43-{uuid.uuid4().hex}"
|
|
139
|
+
receipt = await asyncio.to_thread(kf.deduct, credits, idempotency_key=idem, event_type=f"mcp:{tool}")
|
|
140
|
+
try:
|
|
141
|
+
yield receipt
|
|
142
|
+
except BaseException:
|
|
143
|
+
await asyncio.to_thread(kf.refund, idem, "tool_error")
|
|
144
|
+
raise
|
|
145
|
+
|
|
146
|
+
async def balance(self, headers: Optional[Mapping[str, str]] = None) -> dict[str, Any]:
|
|
147
|
+
import json
|
|
148
|
+
import urllib.parse
|
|
149
|
+
import urllib.request
|
|
150
|
+
|
|
151
|
+
kf = self.client(headers)
|
|
152
|
+
kf.check()
|
|
153
|
+
if not kf.gateway_url:
|
|
154
|
+
raise LicenseError(-32063, "KEYFLINT_URL is not set.", {"reason": "GATEWAY_NOT_CONFIGURED"})
|
|
155
|
+
url = f"{kf.gateway_url}/api/v1/quota/balance?" + urllib.parse.urlencode({"license_key": kf.license_key})
|
|
156
|
+
|
|
157
|
+
def _get() -> dict[str, Any]:
|
|
158
|
+
req = urllib.request.Request(url, headers={"Accept": "application/json"})
|
|
159
|
+
with urllib.request.urlopen(req, timeout=kf.timeout) as resp: # noqa: S310 - configured URL
|
|
160
|
+
data = json.loads(resp.read().decode("utf-8") or "{}")
|
|
161
|
+
return dict(data.get("data", data))
|
|
162
|
+
|
|
163
|
+
return await asyncio.to_thread(_get)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def from_env() -> Billing:
|
|
167
|
+
return Billing(hosted=os.environ.get("NORMA43_TRANSPORT", "stdio") != "stdio")
|