Skip to main content
Glama
momo-mcp
by Tahsine
README.md
# momo-mcp

**FR** — Un serveur [MCP](https://modelcontextprotocol.io) pour Mobile Money en Afrique de l'Ouest francophone. Une seule intégration pour permettre à un agent IA de faire des paiements Mobile Money — sans jongler entre les API de MTN MoMo, Moov Africa et Orange Money.

**EN** — An [MCP](https://modelcontextprotocol.io) server for Mobile Money in Francophone West Africa. One integration that lets an AI agent make Mobile Money payments — without juggling the MTN MoMo, Moov Africa, and Orange Money APIs.

**FR** — Aujourd'hui : **MTN MoMo**, multi-pays, sandbox et production. L'architecture est prête à accueillir Moov Africa et Orange Money sans réécrire le serveur.

**EN** — Today: **MTN MoMo**, multi-country, sandbox and production. The architecture is ready to host Moov Africa and Orange Money without rewriting the server.

> ⚠️ **FR — Statut : à valider avant tout usage réel.** Le serveur est testé (35 tests) et couvre la gestion d'erreurs, le retry et le multi-pays. Il n'a pas encore été audité ni utilisé avec de vrais fonds. En production, valide de bout en bout sur un compte marchand MTN avant de l'exposer.
>
> ⚠️ **EN — Status: validate before any real use.** The server is tested (35 tests) and covers error handling, retries, and multi-country routing. It has not been audited or used with real funds yet. In production, validate end-to-end on an MTN merchant account before exposing it.

---

## Pourquoi / Why

**FR** — La plupart des outils IA génériques (Stripe, PayPal...) ne couvrent pas le Mobile Money, qui reste le moyen de paiement dominant en Afrique de l'Ouest. Les rares intégrations existantes ciblent surtout l'Afrique anglophone/de l'Est. `momo-mcp` comble ce vide pour les développeurs qui construisent des agents IA (assistants d'achat, bots de paiement, outils de facturation) sur ce marché.

**EN** — Most generic AI payment tools (Stripe, PayPal...) don't cover Mobile Money, still the dominant payment method in West Africa. The few existing integrations mostly target anglophone/East Africa. `momo-mcp` fills that gap for developers building AI agents (shopping assistants, payment bots, invoicing tools) in this market.

## Outils exposés / Tools

**FR/EN** — Chaque outil renvoie un dict JSON. Les succès contiennent `ok: true` ; les échecs `ok: false` + un objet `error` (voir [Erreurs](#erreurs--errors)).

| Outil / Tool | FR | EN |
|---|---|---|
| `request_payment` | Demande un paiement à un client (Request-to-Pay). **Asynchrone** : renvoie `PENDING`, à suivre avec `check_payment_status`. | Request a payment from a customer (Request-to-Pay). **Async**: returns `PENDING`, follow up with `check_payment_status`. |
| `disburse_payment` | Envoie de l'argent à un utilisateur (remboursement, salaire, payout). Sortie de fonds — annoté `destructiveHint`. | Send money to a user (refund, salary, payout). Fund outflow — annotated `destructiveHint`. |
| `check_payment_status` | Statut final d'un paiement ou transfert. Utilise `kind` : `collection`, `disbursement` ou `auto`. | Final status of a payment or transfer. Uses `kind`: `collection`, `disbursement`, or `auto`. |
| `validate_account` | Vérifie qu'un numéro a un compte Mobile Money actif avant d'y envoyer des fonds. | Check a number has an active Mobile Money account before sending funds. |

### Cycle d'un paiement / Payment flow

```mermaid
flowchart TD
    A["request_payment(phone_number, amount, currency, external_id, country)"] --> B["status: PENDING + reference_id"]
    B --> C["Customer confirms with PIN on their phone"]
    C --> D["check_payment_status(reference_id, kind=collection, country)"]
    D --> E{"status ?"}
    E -->|SUCCESSFUL| F["Settled - financial_transaction_id"]
    E -->|FAILED| G["Failed - reason"]
    E -->|PENDING| D
```

**FR** — `request_payment` est asynchrone : le statut reste `PENDING` tant que le client n'a pas confirmé. Interroge `check_payment_status` périodiquement (quelques secondes à quelques minutes).

**EN** — `request_payment` is async: status stays `PENDING` until the customer confirms. Poll `check_payment_status` (seconds to minutes).

## Pays supportés / Supported countries

**FR** — Le paramètre `country` de chaque outil sélectionne la config MTN (header `X-Target-Environment`, devise, format de numéro). En sandbox, utilise `benin` (ou n'importe quel pays avec la devise `EUR`). En production, la devise locale du pays est requise.

**EN** — The `country` parameter on every tool selects the MTN config (`X-Target-Environment` header, currency, number format). In sandbox use `benin` (or any country with `EUR`). In production the country's local currency is required.

| `country` | X-Target-Environment | Devise / Currency | Indicatif / Code | Exemple / Example |
|---|---|---|---|---|
| `benin` (défaut) | `mtnbenin` | XOF | 229 | `2290197000000` |
| `ivory_coast` | `mtnivorycoast` | XOF | 225 | `2250700000000` |
| `cameroon` | `mtncameroon` | XAF | 237 | `237600000000` |
| `congo` | `mtncongo` | XAF | 242 | `242060000000` |
| `guinea` | `mtnguineaconakry` | GNF | 224 | `224620000000` |
| `ghana` | `mtnghana` | GHS | 233 | `233240000000` |
| `liberia` | `mtnliberia` | LRD | 231 | `231880000000` |
| `uganda` | `mtnuganda` | UGX | 256 | `256770000000` |
| `zambia` | `mtnzambia` | ZMW | 260 | `260970000000` |
| `rwanda` | `mtnrwanda` | RWF | 250 | `250788000000` |
| `south_africa` | `mtnsouthafrica` | ZAR | 27 | `27820000000` |
| `eswatini` | `mtnswaziland` | SZL | 268 | `26876000000` |

**Alias acceptés / Accepted aliases** : `bj`→benin, `ci`/`cote_d_ivoire`/`côte d'ivoire`→ivory_coast, `cm`→cameroon, `congo_brazzaville`→congo, `guinea_conakry`→guinea, `swaziland`→eswatini. Insensible à la casse, aux accents et aux espaces.

## Installation

```bash
git clone https://github.com/Tahsine/momo-mcp.git
cd momo-mcp
python -m venv .venv && source .venv/bin/activate
pip install -e .
```

### Configuration (sandbox MTN MoMo)

**FR**

1. Crée un compte gratuit sur [momodeveloper.mtn.com](https://momodeveloper.mtn.com) et abonne-toi aux produits **Collection** et **Disbursement** → récupère les deux clés d'abonnement.
2. Provisionne ton **API user** + **API key** (une seule fois) :
   ```bash
   python scripts/provision_mtn.py --subscription-key TA_CLE_COLLECTION
   ```
3. Copie `.env.example` vers `.env` et renseigne les valeurs. Voir le détail des variables dans [`.env.example`](.env.example).

**EN**

1. Create a free account at [momodeveloper.mtn.com](https://momodeveloper.mtn.com), subscribe to **Collection** and **Disbursement**, and grab both subscription keys.
2. Provision your **API user** + **API key** (once):
   ```bash
   python scripts/provision_mtn.py --subscription-key YOUR_COLLECTION_KEY
   ```
3. Copy `.env.example` to `.env` and fill in the values. See [`.env.example`](.env.example) for every variable.

| Variable | Rôle / Purpose |
|---|---|
| `MTN_SUBSCRIPTION_KEY_COLLECTION` | Clé produit Collection (paiements entrants). |
| `MTN_SUBSCRIPTION_KEY_DISBURSEMENT` | Clé produit Disbursement (paiements sortants). |
| `MTN_API_USER` / `MTN_API_KEY` | Identifiants API (sandbox : via `scripts/provision_mtn.py`). |
| `MTN_TARGET_ENVIRONMENT` | `sandbox` pour tester ; toute autre valeur = production (résolu par pays). |
| `MTN_BASE_URL` | `https://sandbox.momodeveloper.mtn.com` ou `https://proxy.momoapi.mtn.com`. |
| `MOMO_PAYER_MESSAGES` | (Optionnel) Messages prédéfinis autorisés comme `payer_message`, séparés par `|`. Vide = seul un message vide est accepté. |
| `MOMO_PAYER_MESSAGE_MAX_LEN` | Longueur max d'un `payer_message` (défaut `60`). |
| `MOMO_MAX_DISBURSEMENT` | Plafond par appel de `disburse_payment`. Vide/invalide/`0` = absent. **Obligatoire en production** (sauf échappatoire). |
| `MOMO_MAX_DAILY_DISBURSEMENT` | Plafond quotidien (UTC) des sorties de fonds, persisté en SQLite. Vide/invalide/`0` = absent. **Obligatoire en production** (sauf échappatoire). |
| `MOMO_STATE_DB` | Fichier du compteur quotidien (défaut `$XDG_STATE_HOME/momo-mcp/state.db`, repli `~/.local/state/momo-mcp/state.db`). |
| `MOMO_ALLOW_UNCAPPED_DISBURSEMENT` | Échappatoire : accepte des sorties **sans plafond** en production. Valeur exacte `true`. |

### Utilisation avec un client MCP / Using an MCP client

**Claude Code**
```bash
claude mcp add momo-mcp -- python -m momo_mcp.server
```

**Inspecteur MCP / MCP Inspector**
```bash
npx @modelcontextprotocol/inspector python -m momo_mcp.server
```

**Docker** — le serveur parle le protocole MCP sur stdin/stdout :
```bash
docker build -t momo-mcp .
docker run -i --env-file .env momo-mcp
```

## Référence des outils / Tool reference

### `request_payment`
| Param | Requis | Description |
|---|---|---|
| `phone_number` | oui | MSISDN du payeur, format international sans `+`. |
| `amount` | oui | Montant en chaîne (ex : `"1000"`). |
| `currency` | oui | Devise du pays (XOF, XAF...) ou `EUR` en sandbox. |
| `external_id` | oui | Référence interne à ton système (ex : n° de commande). |
| `payer_message` | non | Message affiché au payeur. |
| `payee_note` | non | Note interne liée à la transaction. |
| `provider` | non | `mtn_momo` (seul disponible). |
| `country` | non | Pays MTN (défaut `benin`). |

### `disburse_payment`
Mêmes paramètres (`message` au lieu de `payer_message`/`payee_note`) — envoie des fonds sans confirmation PIN.

### `check_payment_status`
| Param | Requis | Description |
|---|---|---|
| `reference_id` | oui | Identifiant retourné par `request_payment` / `disburse_payment`. |
| `kind` | non | `collection`, `disbursement` ou `auto` (défaut). |
| `country` | non | Pays d'origine de la transaction (requis en production). |

### `validate_account`
| Param | Requis | Description |
|---|---|---|
| `phone_number` | oui | MSISDN à vérifier. |
| `country` | non | Pays MTN (défaut `benin`). |

## Erreurs / Errors

**FR** — Aucun outil ne lève d'exception vers l'agent. Toute erreur devient :

```json
{
  "ok": false,
  "error": {
    "code": "devise_invalide",
    "message": "Devise non supportée pour ce compte...",
    "message_en": "Currency not supported for this account.",
    "retryable": false,
    "http_status": 500,
    "detail": "INVALID_CURRENCY"
  }
}
```

**EN** — No tool raises an exception to the agent. Every error becomes the dict above. Check `retryable`: if `true`, retrying the same call may succeed.

**Codes principaux / Main codes** (extrait — voir [`src/momo_mcp/errors.py`](src/momo_mcp/errors.py))

| `code` | Retryable | Cause |
|---|---|---|
| `validation` | non | Paramètre invalide (numéro, montant, devise, `external_id`). |
| `reference_dupliquee` | non | `external_id` déjà utilisé — doit être unique par transaction. |
| `payeur_introuvable` / `beneficiaire_introuvable` | non | Pas de compte Mobile Money actif pour ce numéro. |
| `fonds_insuffisants` | non | Solde payeur insuffisant. |
| `devise_invalide` | non | Devise non supportée (utilise la devise du pays). |
| `environnement_non_autorise` | non | `country`/credentials ne correspondent pas à l'environnement. |
| `authentification_echouee` | non | Clé d'abonnement ou identifiants API invalides. |
| `trop_de_requetes` | oui | Limite de débit MTN atteinte. |
| `timeout_reseau` / `erreur_reseau` | oui | MTN injoignable / délai dépassé. |
| `service_indisponible` / `erreur_serveur_mtn` | oui | Erreur temporaire côté MTN. |
| `erreur_interne` | non | Erreur inattendue du serveur `momo-mcp`. |

**Comportement de retry / Retry behaviour** — Le client réessaie automatiquement **une fois** les erreurs réseau, 5xx et les tokens expirés (401), en réutilisant le même `X-Reference-Id` (idempotent côté MTN). Les erreurs métier (4xx, code MTN) ne sont jamais réessayées automatiquement.

## Sécurité / Security

**FR** — Un serveur MCP qui déplace de l'argent expose des primitives à un LLM. Garde-fous intégrés :

- **Sortie de fonds signalée.** `disburse_payment` porte l'annotation MCP `destructiveHint: true` (et `openWorldHint: true`) ; les clients qui le gèrent peuvent exiger une confirmation humaine. `check_payment_status` et `validate_account` sont `readOnlyHint: true`.
- **Plafonds + barrière de production.** `MOMO_MAX_DISBURSEMENT` (par appel) et `MOMO_MAX_DAILY_DISBURSEMENT` (par jour UTC) bornent les sorties ; dépassement → erreur `plafond_depasse` (non réessayable). Si `MTN_TARGET_ENVIRONMENT != "sandbox"` et qu'aucun plafond n'est défini, le serveur **refuse de démarrer** — sauf échappatoire explicite `MOMO_ALLOW_UNCAPPED_DISBURSEMENT=true`. Le compteur quotidien est **persisté en SQLite** (réservation atomique) : il survit aux redémarrages et se partage entre instances ; une réservation est libérée si l'appel MTN échoue.
- **Canal `payer_message` restreint.** Le message affiché sur le téléphone du payeur n'est jamais du texte libre : il doit être vide ou l'un des messages prédéfinis (`MOMO_PAYER_MESSAGES`), sous plafond de longueur. Cela ferme un vecteur de phishing (un agent rédigeant le texte que la victime lit avant de saisir son PIN).
- **Erreurs structurées.** Aucune stack trace vers le modèle ; toute erreur est un objet `{code, message, retryable}` exploitable sans fuite d'information.
- **Pas de fuite d'identifiants.** Les clés vivent dans `.env` (non commité) ; rien n'est journalisé en clair.

**EN** — A money-moving MCP server hands primitives to an LLM. Built-in guardrails:

- **Outflow is flagged.** `disburse_payment` carries the MCP `destructiveHint: true` annotation (plus `openWorldHint: true`); clients that honour it can require human confirmation. `check_payment_status` and `validate_account` are `readOnlyHint: true`.
- **Caps + production barrier.** `MOMO_MAX_DISBURSEMENT` (per call) and `MOMO_MAX_DAILY_DISBURSEMENT` (per UTC day) bound outflows; exceeding them returns a `plafond_depasse` error (non-retryable). If `MTN_TARGET_ENVIRONMENT != "sandbox"` and no cap is set, the server **refuses to start** — unless the explicit escape hatch `MOMO_ALLOW_UNCAPPED_DISBURSEMENT=true` is set. The daily counter is **persisted in SQLite** (atomic reservation): it survives restarts and is shared across instances; a reservation is released if the MTN call fails.
- **Restricted `payer_message` channel.** The message shown on the payer's phone is never free text: it must be empty or one of the configured `MOMO_PAYER_MESSAGES`, under a length cap. This closes a phishing vector (an agent writing what the victim reads before entering their PIN).
- **Structured errors.** No stack traces to the model; every error is a `{code, message, retryable}` object.
- **No credential leakage.** Keys live in `.env` (not committed); nothing sensitive is logged.

Voir / see [`SECURITY.md`](SECURITY.md).

## Mise en production / Production checklist

**FR**

- [ ] Passer par le **Partner Portal MTN** de ton pays : KYC + compte marchand local requis pour la production.
- [ ] Récupérer les clés d'abonnement production sur `momoapi.mtn.com` et les identifiants API du pays.
- [ ] `MTN_TARGET_ENVIRONMENT` ≠ `sandbox`, `MTN_BASE_URL=https://proxy.momoapi.mtn.com`.
- [ ] Définir `MOMO_MAX_DISBURSEMENT` et `MOMO_MAX_DAILY_DISBURSEMENT` (obligatoires en production), et monter un **volume** sur `MOMO_STATE_DB` en Docker.
- [ ] Renseigner un `providerCallbackHost` réel : `scripts/provision_mtn.py` utilise `https://example.com` par défaut (à adapter).
- [ ] Passer `external_id` **unique par transaction** (sinon `reference_dupliquee`).
- [ ] Valider le parcours complet sur un petit montant réel avant d'exposer l'agent.

**EN**

- [ ] Go through the country **MTN Partner Portal**: KYC + local merchant account are required for production.
- [ ] Get production subscription keys on `momoapi.mtn.com` and the country API credentials.
- [ ] Set `MTN_TARGET_ENVIRONMENT` ≠ `sandbox` and `MTN_BASE_URL=https://proxy.momoapi.mtn.com`.
- [ ] Set `MOMO_MAX_DISBURSEMENT` and `MOMO_MAX_DAILY_DISBURSEMENT` (mandatory in production), and mount a **volume** on `MOMO_STATE_DB` in Docker.
- [ ] Provide a real `providerCallbackHost`: `scripts/provision_mtn.py` defaults to `https://example.com` (change it).
- [ ] Use a **unique `external_id` per transaction** (otherwise `reference_dupliquee`).
- [ ] Run the full flow on a small real amount before exposing the agent.

## Développement / Development

```bash
pip install -e ".[dev]"
ruff check .        # lint
pytest              # 66 tests, aucun réseau réel (respx mocke MTN)
```

**FR** — Les tests n'appellent jamais MTN : `respx` intercepte les requêtes HTTP. Le build Docker est validé en CI (Python 3.10 / 3.12 + build image). Un **test « comme un agent »** (vrai client MCP, appels sandbox réels) vit dans [`docs/agent-test/`](docs/agent-test/) — hors CI.

**EN** — Tests never call MTN: `respx` intercepts HTTP. The Docker build is validated in CI (Python 3.10 / 3.12 + image build). An **agent smoke test** (real MCP client, real sandbox calls) lives in [`docs/agent-test/`](docs/agent-test/) — not run in CI.

## Feuille de route / Roadmap

- [x] MTN MoMo (Collection + Disbursement) — sandbox, multi-pays
- [x] Gestion d'erreurs structurée + retry idempotent
- [x] Tests, CI, image Docker
- [ ] MTN MoMo — validation production
- [ ] Migration `mcp` v2 (FastMCP → MCPServer)
- [ ] Moov Africa
- [ ] Orange Money
- [ ] Webhook optionnel (éviter le polling de `check_payment_status`)

## Contact

**FR** — Construit par **Tahsine** (Borrelle Dev) à Cotonou, Bénin. Questions, retours, intégration sur-mesure (Moov, Orange, autre marché) : ouvre une issue ou écris-moi sur [LinkedIn](https://linkedin.com/in/byborrelle) / [GitHub](https://github.com/Tahsine).

**EN** — Built by **Tahsine** (Borrelle Dev) in Cotonou, Benin. Questions, feedback, custom integrations (Moov, Orange, other markets): open an issue or reach me on [LinkedIn](https://linkedin.com/in/byborrelle) / [GitHub](https://github.com/Tahsine).

## Licence / License

MIT — voir / see [LICENSE](LICENSE).