@zendou/mcp 0.0.0-stage → 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +162 -3
- package/dist/client.js +177 -0
- package/dist/format.js +117 -0
- package/dist/index.js +20 -0
- package/dist/server.js +119 -0
- package/package.json +39 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Souleymane Diallo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,162 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
# @zendou/mcp
|
|
2
|
+
|
|
3
|
+
Serveur MCP (Model Context Protocol) pour [Zendou](https://zendou.app), le service d'email
|
|
4
|
+
transactionnel pour la Guinée. Il permet à votre assistant IA (Claude Desktop, Claude Code,
|
|
5
|
+
Cursor, tout client MCP) d'envoyer un email, de consulter votre solde de crédits, de relire le
|
|
6
|
+
journal des envois et de vérifier l'état de vos domaines.
|
|
7
|
+
|
|
8
|
+
Le serveur tourne **sur votre machine**, avec votre clé API Zendou. Il ne fait que traduire les
|
|
9
|
+
demandes de l'assistant en appels à l'API publique de Zendou : toutes les règles restent
|
|
10
|
+
appliquées par le backend.
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
Aucune installation n'est nécessaire : le client MCP lance le serveur avec `npx`.
|
|
15
|
+
Prérequis : Node.js 18 ou plus.
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npx -y @zendou/mcp
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Le serveur parle MCP sur l'entrée et la sortie standard : lancé à la main, il attend simplement
|
|
22
|
+
des messages. Ses journaux sont écrits sur la sortie d'erreur.
|
|
23
|
+
|
|
24
|
+
## Variables d'environnement
|
|
25
|
+
|
|
26
|
+
| Variable | Rôle |
|
|
27
|
+
|---|---|
|
|
28
|
+
| `ZENDOU_API_KEY` | Votre clé API Zendou (`zd_live_...`). Sans elle, le serveur démarre, mais chaque outil répond par une erreur qui explique comment la configurer. |
|
|
29
|
+
| `ZENDOU_API_URL` | Adresse de l'API, facultative. Par défaut `https://api.zendou.app` (sans `/v1`, un slash final est toléré). |
|
|
30
|
+
|
|
31
|
+
## Créer une clé API
|
|
32
|
+
|
|
33
|
+
1. Connectez-vous au tableau de bord Zendou, menu **Clés API**.
|
|
34
|
+
2. Créez une clé. Pour que l'assistant puisse lire le journal des envois et les domaines,
|
|
35
|
+
cochez **Autoriser la lecture**. Sans cette case, la clé ne permet que d'envoyer des emails
|
|
36
|
+
et de lire le solde.
|
|
37
|
+
3. Copiez la clé : elle n'est affichée qu'une fois.
|
|
38
|
+
|
|
39
|
+
La permission de lecture se choisit à la création et ne peut pas être ajoutée ensuite : si
|
|
40
|
+
vous avez oublié la case, créez une nouvelle clé.
|
|
41
|
+
|
|
42
|
+
## Configuration des clients
|
|
43
|
+
|
|
44
|
+
Formats vérifiés le 23/10/2026 aux documentations officielles. Remplacez `zd_live_VOTRE_CLE`
|
|
45
|
+
par votre clé.
|
|
46
|
+
|
|
47
|
+
### Claude Desktop
|
|
48
|
+
|
|
49
|
+
Dans le menu **Claude** de la barre de menus du système (pas dans les réglages de la fenêtre),
|
|
50
|
+
choisissez **Settings...**, onglet **Developer**, puis le bouton **Edit Config**. Le fichier
|
|
51
|
+
`claude_desktop_config.json` se trouve à :
|
|
52
|
+
|
|
53
|
+
- macOS : `~/Library/Application Support/Claude/claude_desktop_config.json`
|
|
54
|
+
- Windows : `%APPDATA%\Claude\claude_desktop_config.json`
|
|
55
|
+
|
|
56
|
+
Ajoutez :
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
{
|
|
60
|
+
"mcpServers": {
|
|
61
|
+
"zendou": {
|
|
62
|
+
"command": "npx",
|
|
63
|
+
"args": ["-y", "@zendou/mcp"],
|
|
64
|
+
"env": { "ZENDOU_API_KEY": "zd_live_VOTRE_CLE" }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Quittez complètement Claude Desktop, puis relancez-le. En cas de souci, consultez les journaux
|
|
71
|
+
`mcp*.log` dans `~/Library/Logs/Claude` (macOS) ou `%APPDATA%\Claude\logs` (Windows).
|
|
72
|
+
|
|
73
|
+
### Claude Code
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
claude mcp add --env ZENDOU_API_KEY=zd_live_VOTRE_CLE --transport stdio zendou -- npx -y @zendou/mcp
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Les options de Claude Code viennent avant le nom du serveur, et `--` sépare la commande du
|
|
80
|
+
serveur. Si une autre option suit `--env`, elle sépare `--env` du nom.
|
|
81
|
+
|
|
82
|
+
Pour partager la configuration d'un projet, un fichier `.mcp.json` à la racine, au même format
|
|
83
|
+
`mcpServers` (une entrée sans `type` est lue comme stdio). Il accepte `${VAR}` et
|
|
84
|
+
`${VAR:-defaut}` dans `command`, `args` et `env` : la clé est lue dans votre environnement, ne
|
|
85
|
+
la committez pas.
|
|
86
|
+
|
|
87
|
+
```json
|
|
88
|
+
{
|
|
89
|
+
"mcpServers": {
|
|
90
|
+
"zendou": {
|
|
91
|
+
"command": "npx",
|
|
92
|
+
"args": ["-y", "@zendou/mcp"],
|
|
93
|
+
"env": { "ZENDOU_API_KEY": "${ZENDOU_API_KEY}" }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Cursor
|
|
100
|
+
|
|
101
|
+
Fichier `.cursor/mcp.json` (dans le projet) ou `~/.cursor/mcp.json` (global), avec
|
|
102
|
+
l'interpolation `${env:NOM}` :
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
{
|
|
106
|
+
"mcpServers": {
|
|
107
|
+
"zendou": {
|
|
108
|
+
"type": "stdio",
|
|
109
|
+
"command": "npx",
|
|
110
|
+
"args": ["-y", "@zendou/mcp"],
|
|
111
|
+
"env": { "ZENDOU_API_KEY": "${env:ZENDOU_API_KEY}" }
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Outils
|
|
118
|
+
|
|
119
|
+
| Outil | Rôle | Clé requise |
|
|
120
|
+
|---|---|---|
|
|
121
|
+
| `send_email` | Envoie un email (`from`, `to`, `subject`, `html`/`text`, `replyTo`, `headers`) | toute clé active |
|
|
122
|
+
| `get_balance` | Solde de crédits | toute clé active |
|
|
123
|
+
| `list_emails` | Journal des envois, avec filtres (`status`, `channel`, `from`, `to`, `q`) et pagination | clé avec lecture |
|
|
124
|
+
| `get_email` | Détail et état de livraison d'un email | clé avec lecture |
|
|
125
|
+
| `list_domains` | Domaines du compte et leur statut | clé avec lecture |
|
|
126
|
+
| `get_domain` | Statut d'un domaine et enregistrements DNS à créer | clé avec lecture |
|
|
127
|
+
|
|
128
|
+
Règles de `send_email` : un seul destinataire ; `Nom <adresse>` accepté (nom de 128 caractères
|
|
129
|
+
au plus) ; domaine expéditeur vérifié ; `html` ou `text` requis ; `headers` limités à 20, parmi
|
|
130
|
+
`List-Unsubscribe`, `List-Unsubscribe-Post` et `X-*` (hors `X-SES-*`), en ASCII imprimable.
|
|
131
|
+
|
|
132
|
+
Le serveur n'écrit rien d'autre que l'envoi : il ne peut ni créer ni supprimer un domaine, une
|
|
133
|
+
clé ou un identifiant SMTP, ni recharger des crédits.
|
|
134
|
+
|
|
135
|
+
## Sécurité
|
|
136
|
+
|
|
137
|
+
- **La clé reste sur votre machine.** Elle est lue dans l'environnement du processus, envoyée
|
|
138
|
+
uniquement à l'API Zendou, et n'apparaît jamais dans les journaux, les erreurs ou les réponses
|
|
139
|
+
d'outils.
|
|
140
|
+
- **La lecture est facultative.** Elle se décide clé par clé, décochée par défaut. Une clé qui
|
|
141
|
+
fuit avec la lecture expose les destinataires et les sujets de vos emails : n'activez la
|
|
142
|
+
lecture que si vous en avez besoin, et préférez une clé dédiée à l'assistant.
|
|
143
|
+
- **Portée par domaine.** Une clé limitée à certains domaines ne peut envoyer, ni lire, que
|
|
144
|
+
pour ces domaines ; le reste répond comme s'il n'existait pas.
|
|
145
|
+
- **`ZENDOU_API_URL` reçoit votre clé.** Ne la renseignez que pour l'API Zendou, en `https://`.
|
|
146
|
+
Le serveur refuse `http://` sauf pour `localhost` et `127.0.0.1` (développement), n'accepte
|
|
147
|
+
pas les redirections et plafonne les réponses lues à 5 Mo.
|
|
148
|
+
- Ne committez jamais une clé. En cas de doute, révoquez-la depuis le tableau de bord.
|
|
149
|
+
|
|
150
|
+
## Développement
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
npm install
|
|
154
|
+
npm run build # compile vers dist/
|
|
155
|
+
npm run typecheck
|
|
156
|
+
npm run lint # tsc + vérifications maison (scripts/lint.mjs)
|
|
157
|
+
npm test # build puis tests (node:test via tsx), dont un test stdio du binaire
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Licence
|
|
161
|
+
|
|
162
|
+
MIT, voir [LICENSE](LICENSE).
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
export const DEFAULT_API_URL = 'https://api.zendou.app';
|
|
2
|
+
export const DEFAULT_TIMEOUT_MS = 20_000;
|
|
3
|
+
/** Taille maximale d'une réponse de l'API lue en mémoire. */
|
|
4
|
+
export const MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
|
|
5
|
+
/** Erreur renvoyée à l'assistant : le message ne contient jamais la clé. */
|
|
6
|
+
export class ZendouError extends Error {
|
|
7
|
+
status;
|
|
8
|
+
code;
|
|
9
|
+
retryAfter;
|
|
10
|
+
constructor(message, status, code, retryAfter) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.status = status;
|
|
13
|
+
this.code = code;
|
|
14
|
+
this.retryAfter = retryAfter;
|
|
15
|
+
this.name = 'ZendouError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** Normalise l'URL de base : sans slash final et sans suffixe /v1. */
|
|
19
|
+
export function normalizeApiUrl(raw) {
|
|
20
|
+
let url = (raw ?? '').trim() || DEFAULT_API_URL;
|
|
21
|
+
url = url.replace(/\/+$/, '');
|
|
22
|
+
url = url.replace(/\/v1$/, '');
|
|
23
|
+
return url;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Vérifie l'adresse de l'API. Renvoie un message d'erreur français si elle est
|
|
27
|
+
* inutilisable (sans schéma, ni http ni https, ou http hors localhost).
|
|
28
|
+
*/
|
|
29
|
+
export function checkApiUrl(baseUrl) {
|
|
30
|
+
const hint = 'Vérifiez la variable ZENDOU_API_URL.';
|
|
31
|
+
let parsed;
|
|
32
|
+
try {
|
|
33
|
+
parsed = new URL(baseUrl);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return `L'adresse de l'API Zendou (« ${baseUrl} ») n'est pas valide : elle doit commencer par https://. ${hint}`;
|
|
37
|
+
}
|
|
38
|
+
if (parsed.protocol === 'https:')
|
|
39
|
+
return undefined;
|
|
40
|
+
if (parsed.protocol === 'http:') {
|
|
41
|
+
const host = parsed.hostname;
|
|
42
|
+
if (host === 'localhost' || host === '127.0.0.1')
|
|
43
|
+
return undefined;
|
|
44
|
+
return `L'adresse de l'API Zendou (${baseUrl}) utilise http:// : votre clé API serait envoyée en clair. Seul https:// est accepté, sauf pour localhost et 127.0.0.1. ${hint}`;
|
|
45
|
+
}
|
|
46
|
+
return `L'adresse de l'API Zendou (« ${baseUrl} ») n'est pas valide : elle doit commencer par https://. ${hint}`;
|
|
47
|
+
}
|
|
48
|
+
export class ZendouClient {
|
|
49
|
+
baseUrl;
|
|
50
|
+
urlError;
|
|
51
|
+
apiKey;
|
|
52
|
+
userAgent;
|
|
53
|
+
timeoutMs;
|
|
54
|
+
fetchImpl;
|
|
55
|
+
constructor(options) {
|
|
56
|
+
this.apiKey = options.apiKey?.trim() || undefined;
|
|
57
|
+
this.baseUrl = normalizeApiUrl(options.apiUrl);
|
|
58
|
+
this.urlError = checkApiUrl(this.baseUrl);
|
|
59
|
+
this.userAgent = options.userAgent;
|
|
60
|
+
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
61
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
62
|
+
}
|
|
63
|
+
get hasKey() {
|
|
64
|
+
return this.apiKey !== undefined;
|
|
65
|
+
}
|
|
66
|
+
/** Retire toute occurrence de la clé d'un texte. */
|
|
67
|
+
redact(text) {
|
|
68
|
+
return this.apiKey ? text.split(this.apiKey).join('[clé masquée]') : text;
|
|
69
|
+
}
|
|
70
|
+
async request(method, path, options = {}) {
|
|
71
|
+
if (!this.apiKey) {
|
|
72
|
+
throw new ZendouError(MISSING_KEY_MESSAGE);
|
|
73
|
+
}
|
|
74
|
+
if (this.urlError) {
|
|
75
|
+
throw new ZendouError(this.redact(this.urlError));
|
|
76
|
+
}
|
|
77
|
+
const url = new URL(this.baseUrl + path);
|
|
78
|
+
for (const [k, v] of Object.entries(options.query ?? {})) {
|
|
79
|
+
if (v !== undefined && v !== '')
|
|
80
|
+
url.searchParams.set(k, String(v));
|
|
81
|
+
}
|
|
82
|
+
const headers = {
|
|
83
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
84
|
+
'User-Agent': this.userAgent,
|
|
85
|
+
Accept: 'application/json',
|
|
86
|
+
};
|
|
87
|
+
const init = {
|
|
88
|
+
method,
|
|
89
|
+
headers,
|
|
90
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
91
|
+
redirect: 'error',
|
|
92
|
+
};
|
|
93
|
+
if (options.body !== undefined) {
|
|
94
|
+
headers['Content-Type'] = 'application/json';
|
|
95
|
+
init.body = JSON.stringify(options.body);
|
|
96
|
+
}
|
|
97
|
+
let response;
|
|
98
|
+
try {
|
|
99
|
+
response = await this.fetchImpl(url, init);
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
const name = err instanceof Error ? err.name : '';
|
|
103
|
+
if (name === 'TimeoutError' || name === 'AbortError') {
|
|
104
|
+
throw new ZendouError(`L'API Zendou n'a pas répondu en ${Math.round(this.timeoutMs / 1000)} secondes (${this.baseUrl}). Réessayez dans un instant.`);
|
|
105
|
+
}
|
|
106
|
+
throw new ZendouError(this.redact(`Impossible de joindre l'API Zendou (${this.baseUrl}). Vérifiez votre connexion et la variable ZENDOU_API_URL.`));
|
|
107
|
+
}
|
|
108
|
+
const tooBig = () => new ZendouError(`La réponse de l'API Zendou dépasse ${MAX_RESPONSE_BYTES / (1024 * 1024)} Mo : elle est refusée. Vérifiez la variable ZENDOU_API_URL.`, response.status);
|
|
109
|
+
const declared = Number(response.headers.get('content-length'));
|
|
110
|
+
if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) {
|
|
111
|
+
void response.body?.cancel().catch(() => undefined);
|
|
112
|
+
throw tooBig();
|
|
113
|
+
}
|
|
114
|
+
let raw;
|
|
115
|
+
try {
|
|
116
|
+
raw = await readLimited(response, tooBig);
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
if (err instanceof ZendouError)
|
|
120
|
+
throw err;
|
|
121
|
+
throw new ZendouError(`Lecture de la réponse de l'API Zendou interrompue (${this.baseUrl}). Réessayez dans un instant.`);
|
|
122
|
+
}
|
|
123
|
+
let data;
|
|
124
|
+
try {
|
|
125
|
+
data = raw ? JSON.parse(raw) : undefined;
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
data = undefined;
|
|
129
|
+
}
|
|
130
|
+
if (!response.ok) {
|
|
131
|
+
throw this.toError(response, data);
|
|
132
|
+
}
|
|
133
|
+
if (data === undefined) {
|
|
134
|
+
throw new ZendouError(`Réponse inattendue de l'API Zendou (HTTP ${response.status}) : le corps n'est pas du JSON.`, response.status);
|
|
135
|
+
}
|
|
136
|
+
return data;
|
|
137
|
+
}
|
|
138
|
+
toError(response, data) {
|
|
139
|
+
const body = (data && typeof data === 'object' ? data : {});
|
|
140
|
+
const rawMessage = typeof body.message === 'string' ? body.message : undefined;
|
|
141
|
+
const errors = Array.isArray(body.errors)
|
|
142
|
+
? body.errors.filter((e) => typeof e === 'string')
|
|
143
|
+
: [];
|
|
144
|
+
let message = rawMessage ?? errors[0] ?? `Erreur HTTP ${response.status}.`;
|
|
145
|
+
// Le backend garantit message === errors.join(' ') : on n'ajoute que ce que message ne contient pas.
|
|
146
|
+
const extra = errors.filter((e) => !message.includes(e));
|
|
147
|
+
if (extra.length > 0)
|
|
148
|
+
message += ` (${extra.join(' ; ')})`;
|
|
149
|
+
const code = typeof body.code === 'string' ? body.code : undefined;
|
|
150
|
+
const retryAfter = response.headers.get('retry-after') ?? undefined;
|
|
151
|
+
return new ZendouError(this.redact(message), response.status, code, retryAfter);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/** Lit le corps en flux et s'arrête dès que le plafond est dépassé. */
|
|
155
|
+
async function readLimited(response, tooBig) {
|
|
156
|
+
if (!response.body)
|
|
157
|
+
return '';
|
|
158
|
+
const reader = response.body.getReader();
|
|
159
|
+
const chunks = [];
|
|
160
|
+
let total = 0;
|
|
161
|
+
for (;;) {
|
|
162
|
+
const { done, value } = await reader.read();
|
|
163
|
+
if (done)
|
|
164
|
+
break;
|
|
165
|
+
total += value.byteLength;
|
|
166
|
+
if (total > MAX_RESPONSE_BYTES) {
|
|
167
|
+
await reader.cancel().catch(() => undefined);
|
|
168
|
+
throw tooBig();
|
|
169
|
+
}
|
|
170
|
+
chunks.push(value);
|
|
171
|
+
}
|
|
172
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
173
|
+
}
|
|
174
|
+
export const MISSING_KEY_MESSAGE = "La clé API Zendou n'est pas configurée. Créez une clé dans le tableau de bord Zendou (Clés API), " +
|
|
175
|
+
'puis renseignez-la dans la variable d\'environnement ZENDOU_API_KEY de la configuration MCP de votre assistant ' +
|
|
176
|
+
'(bloc "env" du serveur zendou), et redémarrez l\'assistant. Pour lire le journal et les domaines, ' +
|
|
177
|
+
'cochez « Autoriser la lecture » à la création de la clé.';
|
package/dist/format.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { ZendouError } from './client.js';
|
|
2
|
+
const str = (v) => (v === null || v === undefined || v === '' ? '-' : String(v));
|
|
3
|
+
const STATUS_LABELS = {
|
|
4
|
+
QUEUED: "en file d'attente",
|
|
5
|
+
SENT: 'envoyé',
|
|
6
|
+
DELIVERED: 'délivré',
|
|
7
|
+
BOUNCED: 'rejeté par le destinataire (rebond)',
|
|
8
|
+
COMPLAINED: 'signalé comme indésirable',
|
|
9
|
+
REJECTED: 'refusé',
|
|
10
|
+
FAILED: 'échec',
|
|
11
|
+
SUPPRESSED: 'supprimé (adresse sur liste de suppression)',
|
|
12
|
+
};
|
|
13
|
+
function statusLabel(s) {
|
|
14
|
+
const key = String(s);
|
|
15
|
+
return STATUS_LABELS[key] ? `${key} (${STATUS_LABELS[key]})` : str(s);
|
|
16
|
+
}
|
|
17
|
+
export function formatSend(r) {
|
|
18
|
+
return `Email accepté pour envoi.\nIdentifiant : ${str(r.id)}\nStatut : ${statusLabel(r.status)}\nUtilisez get_email avec cet identifiant pour suivre la livraison (clé avec lecture requise).`;
|
|
19
|
+
}
|
|
20
|
+
export function formatBalance(r) {
|
|
21
|
+
return [
|
|
22
|
+
`Crédits disponibles : ${str(r.balance)}`,
|
|
23
|
+
`Total acheté : ${str(r.totalPurchased)}`,
|
|
24
|
+
`Total offert : ${str(r.totalGifted)}`,
|
|
25
|
+
`Total consommé : ${str(r.totalConsumed)}`,
|
|
26
|
+
].join('\n');
|
|
27
|
+
}
|
|
28
|
+
export function formatEmailList(r) {
|
|
29
|
+
const items = Array.isArray(r.items) ? r.items : [];
|
|
30
|
+
const head = `${str(r.total)} email(s) au total, page ${str(r.page)} sur ${str(r.totalPages)} (${str(r.limit)} par page).`;
|
|
31
|
+
if (items.length === 0)
|
|
32
|
+
return `${head}\nAucun email ne correspond.`;
|
|
33
|
+
const lines = items.map((e) => `- ${str(e.publicId)} | ${statusLabel(e.status)} | de ${str(e.fromAddress)} à ${str(e.toAddress)} | « ${str(e.subject)} » | canal ${str(e.channel)} | mis en file ${str(e.queuedAt)} | dernier événement ${str(e.lastEventAt)}`);
|
|
34
|
+
return `${head}\n${lines.join('\n')}`;
|
|
35
|
+
}
|
|
36
|
+
export function formatEmail(e) {
|
|
37
|
+
const lines = [
|
|
38
|
+
`Email ${str(e.publicId)}`,
|
|
39
|
+
`Statut : ${statusLabel(e.status)}`,
|
|
40
|
+
`De : ${str(e.fromAddress)}`,
|
|
41
|
+
`À : ${str(e.toAddress)}`,
|
|
42
|
+
`Sujet : ${str(e.subject)}`,
|
|
43
|
+
`Canal : ${str(e.channel)}`,
|
|
44
|
+
`Mis en file : ${str(e.queuedAt)}`,
|
|
45
|
+
`Envoyé : ${str(e.sentAt)}`,
|
|
46
|
+
`Délivré : ${str(e.deliveredAt)}`,
|
|
47
|
+
`Dernier événement : ${str(e.lastEventAt)}`,
|
|
48
|
+
];
|
|
49
|
+
if (e.errorMessage)
|
|
50
|
+
lines.push(`Message d'erreur : ${str(e.errorMessage)}`);
|
|
51
|
+
if (e.sesMessageId)
|
|
52
|
+
lines.push(`Identifiant SES : ${str(e.sesMessageId)}`);
|
|
53
|
+
if (e.smtpCredentialId)
|
|
54
|
+
lines.push(`Identifiant SMTP : ${str(e.smtpCredentialId)}`);
|
|
55
|
+
return lines.join('\n');
|
|
56
|
+
}
|
|
57
|
+
export function formatDomainList(r) {
|
|
58
|
+
const items = Array.isArray(r) ? r : [];
|
|
59
|
+
if (items.length === 0)
|
|
60
|
+
return 'Aucun domaine enregistré sur ce compte (ou aucun dans la portée de cette clé).';
|
|
61
|
+
const lines = items.map((d) => `- ${str(d.name)} | id ${str(d.id)} | statut ${str(d.status)} | vérifié le ${str(d.verifiedAt)} | créé le ${str(d.createdAt)}`);
|
|
62
|
+
return `${items.length} domaine(s) :\n${lines.join('\n')}`;
|
|
63
|
+
}
|
|
64
|
+
function formatRecords(title, records, withPurpose) {
|
|
65
|
+
const list = Array.isArray(records) ? records : [];
|
|
66
|
+
if (list.length === 0)
|
|
67
|
+
return [`${title} : aucun.`];
|
|
68
|
+
const out = [`${title} :`];
|
|
69
|
+
for (const r of list) {
|
|
70
|
+
const prio = r.priority !== undefined && r.priority !== null ? `, priorité ${str(r.priority)}` : '';
|
|
71
|
+
out.push(`- Type ${str(r.type)}, nom ${str(r.name)}${prio}`);
|
|
72
|
+
out.push(` Valeur : ${str(r.value)}`);
|
|
73
|
+
if (withPurpose) {
|
|
74
|
+
if (r.purpose)
|
|
75
|
+
out.push(` Rôle : ${str(r.purpose)}`);
|
|
76
|
+
if (r.note)
|
|
77
|
+
out.push(` Note : ${str(r.note)}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
export function formatDomain(d) {
|
|
83
|
+
const lines = [
|
|
84
|
+
`Domaine ${str(d.name)} (id ${str(d.id)})`,
|
|
85
|
+
`Statut : ${str(d.status)}`,
|
|
86
|
+
`Vérifié le : ${str(d.verifiedAt)}`,
|
|
87
|
+
`Créé le : ${str(d.createdAt)}`,
|
|
88
|
+
`Domaine MAIL FROM : ${str(d.mailFromDomain)} (statut ${str(d.mailFromStatus)})`,
|
|
89
|
+
...formatRecords('Enregistrements DNS requis (DKIM)', d.dkimRecords, false),
|
|
90
|
+
...formatRecords('Enregistrements DNS recommandés', d.recommendedRecords, true),
|
|
91
|
+
];
|
|
92
|
+
return lines.join('\n');
|
|
93
|
+
}
|
|
94
|
+
const READ_HELP = 'Pour utiliser cet outil, créez une nouvelle clé API dans le tableau de bord Zendou (menu Clés API) en cochant « Autoriser la lecture », ' +
|
|
95
|
+
'puis mettez-la dans ZENDOU_API_KEY. La lecture se choisit à la création de la clé et ne peut pas être ajoutée ensuite.';
|
|
96
|
+
/** Transforme une erreur en texte d'erreur d'outil lisible, en français. */
|
|
97
|
+
export function describeError(err) {
|
|
98
|
+
if (!(err instanceof ZendouError)) {
|
|
99
|
+
return 'Erreur inattendue dans le serveur MCP Zendou. Réessayez ; si le problème persiste, signalez-le.';
|
|
100
|
+
}
|
|
101
|
+
if (err.status === undefined)
|
|
102
|
+
return err.message;
|
|
103
|
+
const parts = [`Erreur Zendou (HTTP ${err.status}${err.code ? `, code ${err.code}` : ''}) : ${err.message}`];
|
|
104
|
+
if (err.status === 403 && /lecture/i.test(err.message) && !err.code)
|
|
105
|
+
parts.push(READ_HELP);
|
|
106
|
+
if (err.status === 429) {
|
|
107
|
+
const n = Number(err.retryAfter);
|
|
108
|
+
parts.push(err.retryAfter !== undefined && Number.isFinite(n)
|
|
109
|
+
? `Trop de requêtes : réessayez dans ${n} seconde(s).`
|
|
110
|
+
: 'Trop de requêtes : patientez un moment avant de réessayer.');
|
|
111
|
+
}
|
|
112
|
+
if (err.status === 401)
|
|
113
|
+
parts.push('Vérifiez ZENDOU_API_KEY : la clé est peut-être erronée ou révoquée.');
|
|
114
|
+
if (err.status === 402)
|
|
115
|
+
parts.push('Rechargez vos crédits depuis le tableau de bord Zendou.');
|
|
116
|
+
return parts.join('\n');
|
|
117
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
import { ZendouClient } from './client.js';
|
|
4
|
+
import { createServer, VERSION } from './server.js';
|
|
5
|
+
async function main() {
|
|
6
|
+
const client = new ZendouClient({
|
|
7
|
+
apiKey: process.env.ZENDOU_API_KEY,
|
|
8
|
+
apiUrl: process.env.ZENDOU_API_URL,
|
|
9
|
+
userAgent: `zendou-mcp/${VERSION}`,
|
|
10
|
+
});
|
|
11
|
+
if (!client.hasKey) {
|
|
12
|
+
console.error('[zendou-mcp] ZENDOU_API_KEY absente : les outils renverront une erreur de configuration.');
|
|
13
|
+
}
|
|
14
|
+
await createServer(client).connect(new StdioServerTransport());
|
|
15
|
+
console.error(`[zendou-mcp] démarré (v${VERSION}, API ${client.baseUrl}).`);
|
|
16
|
+
}
|
|
17
|
+
main().catch((err) => {
|
|
18
|
+
console.error('[zendou-mcp] arrêt sur erreur :', err instanceof Error ? err.message : 'inconnue');
|
|
19
|
+
process.exit(1);
|
|
20
|
+
});
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { describeError, formatBalance, formatDomain, formatDomainList, formatEmail, formatEmailList, formatSend, } from './format.js';
|
|
4
|
+
export const VERSION = '0.1.0';
|
|
5
|
+
async function run(client, fn) {
|
|
6
|
+
try {
|
|
7
|
+
return { content: [{ type: 'text', text: client.redact(await fn()) }] };
|
|
8
|
+
}
|
|
9
|
+
catch (err) {
|
|
10
|
+
return { content: [{ type: 'text', text: client.redact(describeError(err)) }], isError: true };
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
const READ_NOTE = " Nécessite une clé API créée avec « Autoriser la lecture » ; sinon l'API répond 403.";
|
|
14
|
+
export function createServer(client) {
|
|
15
|
+
const server = new McpServer({ name: 'zendou', version: VERSION });
|
|
16
|
+
server.registerTool('send_email', {
|
|
17
|
+
title: 'Envoyer un email',
|
|
18
|
+
description: 'Envoie un email transactionnel via Zendou. Règles : un seul destinataire dans `to` (une adresse). ' +
|
|
19
|
+
"`from` et `to` acceptent `adresse@domaine.com` ou `Nom <adresse@domaine.com>` (nom d'affichage de 128 caractères au plus). " +
|
|
20
|
+
'Le domaine de `from` (et de `replyTo`) doit être un domaine vérifié de votre compte. ' +
|
|
21
|
+
'Fournissez `html`, `text` ou les deux (au moins un des deux est requis). ' +
|
|
22
|
+
'`headers` : 20 en-têtes au plus ; seuls `List-Unsubscribe`, `List-Unsubscribe-Post` et les en-têtes `X-*` (hors `X-SES-*`) sont acceptés, en ASCII imprimable. ' +
|
|
23
|
+
"Réponse : un identifiant et le statut `queued` (en file d'envoi) ou `suppressed` (destinataire sur la liste de suppression : rien n'est envoyé ni débité) ; un envoi mis en file consomme 1 crédit. Le backend reste l'autorité sur toutes les règles.",
|
|
24
|
+
inputSchema: {
|
|
25
|
+
from: z
|
|
26
|
+
.string()
|
|
27
|
+
.min(1)
|
|
28
|
+
.describe('Expéditeur : `adresse@domaine.com` ou `Nom <adresse@domaine.com>` (nom de 128 caractères au plus). Domaine vérifié obligatoire.'),
|
|
29
|
+
to: z
|
|
30
|
+
.string()
|
|
31
|
+
.min(1)
|
|
32
|
+
.describe('Destinataire unique : `adresse@exemple.com` ou `Nom <adresse@exemple.com>`. Une seule adresse.'),
|
|
33
|
+
subject: z.string().min(1).max(300).describe("Sujet de l'email (300 caractères au plus)."),
|
|
34
|
+
html: z.string().optional().describe('Corps HTML (500 Ko au plus). `html` ou `text` est requis.'),
|
|
35
|
+
text: z.string().optional().describe('Corps texte brut (500 Ko au plus). `html` ou `text` est requis.'),
|
|
36
|
+
replyTo: z
|
|
37
|
+
.string()
|
|
38
|
+
.min(1)
|
|
39
|
+
.optional()
|
|
40
|
+
.describe('Adresse de réponse. Son domaine doit aussi être un domaine vérifié de votre compte.'),
|
|
41
|
+
headers: z
|
|
42
|
+
.record(z.string(), z.string())
|
|
43
|
+
.optional()
|
|
44
|
+
.describe('En-têtes personnalisés, 20 au plus : `List-Unsubscribe`, `List-Unsubscribe-Post`, `X-*` hors `X-SES-*`. ASCII imprimable.'),
|
|
45
|
+
},
|
|
46
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
47
|
+
}, (args) => run(client, async () => {
|
|
48
|
+
const body = { from: args.from, to: args.to, subject: args.subject };
|
|
49
|
+
if (args.replyTo !== undefined)
|
|
50
|
+
body.replyTo = args.replyTo;
|
|
51
|
+
if (args.html !== undefined)
|
|
52
|
+
body.html = args.html;
|
|
53
|
+
if (args.text !== undefined)
|
|
54
|
+
body.text = args.text;
|
|
55
|
+
if (args.headers !== undefined)
|
|
56
|
+
body.headers = args.headers;
|
|
57
|
+
return formatSend(await client.request('POST', '/v1/emails', { body }));
|
|
58
|
+
}));
|
|
59
|
+
server.registerTool('get_balance', {
|
|
60
|
+
title: 'Solde de crédits',
|
|
61
|
+
description: 'Donne le solde de crédits Zendou (1 crédit = 1 email accepté) : crédits disponibles, achetés, offerts et consommés. Fonctionne avec toute clé API active.',
|
|
62
|
+
inputSchema: {},
|
|
63
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
64
|
+
}, () => run(client, async () => formatBalance(await client.request('GET', '/v1/billing/balance'))));
|
|
65
|
+
server.registerTool('list_emails', {
|
|
66
|
+
title: 'Lister les emails envoyés',
|
|
67
|
+
description: "Liste le journal des emails envoyés (le plus récent d'abord), avec filtres et pagination : identifiant public, expéditeur, destinataire, sujet, statut, canal, dates." +
|
|
68
|
+
READ_NOTE,
|
|
69
|
+
inputSchema: {
|
|
70
|
+
status: z
|
|
71
|
+
.enum(['QUEUED', 'SENT', 'DELIVERED', 'BOUNCED', 'COMPLAINED', 'REJECTED', 'FAILED', 'SUPPRESSED'])
|
|
72
|
+
.optional()
|
|
73
|
+
.describe('Filtre sur le statut.'),
|
|
74
|
+
channel: z.enum(['API', 'SMTP']).optional().describe("Filtre sur le canal d'envoi."),
|
|
75
|
+
page: z.number().int().min(1).optional().describe('Numéro de page, à partir de 1.'),
|
|
76
|
+
limit: z.number().int().min(1).max(100).optional().describe("Nombre d'éléments par page (1 à 100)."),
|
|
77
|
+
from: z.string().optional().describe('Début de période, date ISO 8601 (ex. 2026-10-01 ou 2026-10-01T00:00:00Z).'),
|
|
78
|
+
to: z.string().optional().describe('Fin de période, date ISO 8601.'),
|
|
79
|
+
q: z.string().optional().describe('Recherche libre (adresse, sujet).'),
|
|
80
|
+
},
|
|
81
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
82
|
+
}, (args) => run(client, async () => formatEmailList(await client.request('GET', '/v1/emails', {
|
|
83
|
+
query: {
|
|
84
|
+
status: args.status,
|
|
85
|
+
channel: args.channel,
|
|
86
|
+
page: args.page,
|
|
87
|
+
limit: args.limit,
|
|
88
|
+
from: args.from,
|
|
89
|
+
to: args.to,
|
|
90
|
+
q: args.q,
|
|
91
|
+
},
|
|
92
|
+
}))));
|
|
93
|
+
server.registerTool('get_email', {
|
|
94
|
+
title: "Détail d'un email",
|
|
95
|
+
description: "Donne le détail et l'état de livraison d'un email à partir de son identifiant public (celui renvoyé par send_email ou list_emails) : statut, dates, message d'erreur éventuel." +
|
|
96
|
+
READ_NOTE,
|
|
97
|
+
inputSchema: {
|
|
98
|
+
publicId: z.string().min(1).describe("Identifiant public de l'email."),
|
|
99
|
+
},
|
|
100
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
101
|
+
}, (args) => run(client, async () => formatEmail(await client.request('GET', `/v1/emails/${encodeURIComponent(args.publicId)}`))));
|
|
102
|
+
server.registerTool('list_domains', {
|
|
103
|
+
title: 'Lister les domaines',
|
|
104
|
+
description: "Liste les domaines d'envoi du compte avec leur statut de vérification (identifiant, nom, statut, dates). Pour les enregistrements DNS, utilisez get_domain." +
|
|
105
|
+
READ_NOTE,
|
|
106
|
+
inputSchema: {},
|
|
107
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
108
|
+
}, () => run(client, async () => formatDomainList(await client.request('GET', '/v1/domains'))));
|
|
109
|
+
server.registerTool('get_domain', {
|
|
110
|
+
title: "Détail d'un domaine",
|
|
111
|
+
description: "Donne le statut d'un domaine et les enregistrements DNS prêts à copier chez le registrar : enregistrements requis (DKIM, MAIL FROM) et recommandés (type, nom, valeur, priorité, rôle). Utile pour dire ce qui manque pour vérifier un domaine." +
|
|
112
|
+
READ_NOTE,
|
|
113
|
+
inputSchema: {
|
|
114
|
+
id: z.string().min(1).describe('Identifiant du domaine (champ `id` de list_domains).'),
|
|
115
|
+
},
|
|
116
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
117
|
+
}, (args) => run(client, async () => formatDomain(await client.request('GET', `/v1/domains/${encodeURIComponent(args.id)}`))));
|
|
118
|
+
return server;
|
|
119
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,41 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zendou/mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Serveur MCP Zendou : envoyer des emails et consulter votre compte depuis votre assistant IA.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Souleymane Diallo",
|
|
8
|
+
"bin": {
|
|
9
|
+
"zendou-mcp": "dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"main": "dist/server.js",
|
|
12
|
+
"files": [
|
|
13
|
+
"dist",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=18"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsc -p tsconfig.build.json",
|
|
22
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
23
|
+
"lint": "tsc --noEmit -p tsconfig.json && node scripts/lint.mjs",
|
|
24
|
+
"test": "npm run build && tsx --test test/*.test.ts",
|
|
25
|
+
"prepublishOnly": "npm run build"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"mcp",
|
|
29
|
+
"zendou",
|
|
30
|
+
"email"
|
|
31
|
+
],
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
34
|
+
"zod": "^4.6.7"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/node": "^26.6.5",
|
|
38
|
+
"tsx": "^4.23.15",
|
|
39
|
+
"typescript": "^7.0.2"
|
|
40
|
+
}
|
|
41
|
+
}
|