@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 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
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }