@clicbase/mcp 0.1.2 → 0.2.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/README.md +32 -0
- package/clicbase-mcp.mjs +181 -6
- package/package.json +1 -1
- package/portee.mjs +8 -0
- package/savoir.mjs +2 -0
package/README.md
CHANGED
|
@@ -18,6 +18,38 @@ Outils :
|
|
|
18
18
|
- `enable_realtime` — active le temps réel sur une table.
|
|
19
19
|
- `set_oauth_provider` — configure Google OAuth.
|
|
20
20
|
- `set_email_smtp` — configure un SMTP (sinon SMTP interne Clicbase par défaut).
|
|
21
|
+
- `list_site_files` — liste les fichiers d'un site heberge (nom, taille, date).
|
|
22
|
+
- `read_site_file` — rend le contenu d'UN fichier, en base64 cote API, decode cote outil.
|
|
23
|
+
- `clicbase_conventions` — les regles de la plateforme : policies RLS, droits, PostgREST.
|
|
24
|
+
|
|
25
|
+
## Les fichiers d'un site
|
|
26
|
+
|
|
27
|
+
Jusqu'a la 0.2.0, une cle pouvait ECRASER les fichiers d'un site sans pouvoir
|
|
28
|
+
les lire : l'interdit etait pose du mauvais cote, puisque ecrire est
|
|
29
|
+
strictement plus dangereux que lire. La seule issue etait un mot de passe SFTP,
|
|
30
|
+
c'est-a-dire un secret durable colle dans une conversation pour contourner un
|
|
31
|
+
endpoint absent.
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
list_site_files(site_id) -> un niveau de la racine publique
|
|
35
|
+
list_site_files(site_id, "assets") -> on descend
|
|
36
|
+
read_site_file(site_id, "index.html") -> le contenu, decode
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
L'id du site vient de `GET /api/v1/admin/sites`.
|
|
40
|
+
|
|
41
|
+
⚠️ `list_site_files` est une LECTURE, `read_site_file` est un SECRET. Le nom
|
|
42
|
+
d'un fichier revele une structure ; son contenu peut porter des identifiants
|
|
43
|
+
dans un `config.php` ou un `settings.js`. Le second disparait donc du mode
|
|
44
|
+
restreint, comme `get_credentials`.
|
|
45
|
+
|
|
46
|
+
⚠️ LES FICHIERS CACHES SONT HORS D'ATTEINTE, a tout niveau : `.env` comme
|
|
47
|
+
`assets/.env`. Le serveur refuse tout segment commencant par un point, aussi
|
|
48
|
+
bien au depot qu'a la lecture. Cette garde existait pour empecher qu'ils soient
|
|
49
|
+
SERVIS par le serveur web ; elle protege exactement le bon flanc.
|
|
50
|
+
|
|
51
|
+
Plafond : 2 Mo cote API, 256 Ko cote outil. Au-dela, le telechargement du
|
|
52
|
+
tableau de bord ou le SFTP.
|
|
21
53
|
|
|
22
54
|
## Lecture seule
|
|
23
55
|
|
package/clicbase-mcp.mjs
CHANGED
|
@@ -38,10 +38,112 @@ if (!CLE) {
|
|
|
38
38
|
const out = (d) => ({ content: [{ type: "text", text: typeof d === "string" ? d : JSON.stringify(d, null, 2) }] });
|
|
39
39
|
const fail = (e) => ({ isError: true, content: [{ type: "text", text: String(e?.message ?? e) }] });
|
|
40
40
|
|
|
41
|
+
// L'API de LECTURE des projets, derivee de celle de provisioning.
|
|
42
|
+
const API_LECTURE = API.replace(/\/databases\/?$/, "/admin/projects");
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Traduit un statut HTTP en phrase utile.
|
|
46
|
+
*
|
|
47
|
+
* ⚠️ « Clicbase 403 » NE DIT RIEN, ET ON L'A VU COUTER UNE HEURE. Le
|
|
48
|
+
* 2026-09-17, une session a lu ce nombre, conclu que la cle etait morte, et
|
|
49
|
+
* cherche du mauvais cote. Mesure ensuite : la cle etait vivante, jamais
|
|
50
|
+
* revoquee, et cette route ne peut PAS repondre 403 (400 sans en-tete, 401 cle
|
|
51
|
+
* refusee, 429 debit, 500 provisioning). Le nombre seul envoie chercher au
|
|
52
|
+
* hasard.
|
|
53
|
+
*/
|
|
54
|
+
/**
|
|
55
|
+
* Lit une reponse en echec SANS PERDRE CE QU'ELLE CONTENAIT.
|
|
56
|
+
*
|
|
57
|
+
* ⚠️ `res.json().catch(() => ({}))` EFFACE LA PREUVE. Le 2026-09-17, une session
|
|
58
|
+
* a rapporte « Clicbase 403: {} » sur trois tentatives. Ce `{}` ne voulait pas
|
|
59
|
+
* dire « corps vide » : il voulait dire « corps ILLISIBLE en JSON », donc pas
|
|
60
|
+
* notre application, qui rend toujours un champ `error`. L'information qui
|
|
61
|
+
* aurait tranche en dix secondes, l'en-tete `cf-ray` et les premiers octets du
|
|
62
|
+
* corps, avait ete jetee par le `catch`.
|
|
63
|
+
*
|
|
64
|
+
* On garde donc : le statut, l'origine declaree par le serveur, l'identifiant
|
|
65
|
+
* Cloudflare s'il existe, et le debut du corps tel quel.
|
|
66
|
+
*/
|
|
67
|
+
async function lireEchec(res) {
|
|
68
|
+
const brut = await res.text().catch(() => "");
|
|
69
|
+
let json = null;
|
|
70
|
+
try {
|
|
71
|
+
json = JSON.parse(brut);
|
|
72
|
+
} catch {
|
|
73
|
+
/* pas du JSON : c'est precisement ce qu'on veut savoir */
|
|
74
|
+
}
|
|
75
|
+
const via = res.headers.get("cf-ray")
|
|
76
|
+
? `Cloudflare (cf-ray ${res.headers.get("cf-ray")})`
|
|
77
|
+
: (res.headers.get("server") ?? "origine inconnue");
|
|
78
|
+
return {
|
|
79
|
+
json,
|
|
80
|
+
// ⚠️ UN CORPS NON-JSON EST LA SIGNATURE D'UN REFUS EN AMONT. On le dit en
|
|
81
|
+
// clair au lieu de rendre un objet vide qui ressemble a une reponse.
|
|
82
|
+
detail: json
|
|
83
|
+
? JSON.stringify(json)
|
|
84
|
+
: brut.trim()
|
|
85
|
+
? `reponse NON-JSON de ${via} : ${brut.replace(/\s+/g, " ").slice(0, 180)}`
|
|
86
|
+
: `corps vide, servi par ${via} — la requete n'a probablement pas atteint Clicbase.`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function expliquer(statut) {
|
|
91
|
+
if (statut === 400) return "requete incomplete : le champ `name` est obligatoire.";
|
|
92
|
+
if (statut === 401)
|
|
93
|
+
return "cle refusee : invalide, expiree ou revoquee. Attention, le bouton « Rouler » du tableau de bord REMPLACE le secret : l'ancien meurt aussitot.";
|
|
94
|
+
if (statut === 403)
|
|
95
|
+
return "hors du perimetre de cette cle. Une cle de Docker ne voit que son Docker. Verifie sa portee sur /api/v1/admin/me.";
|
|
96
|
+
if (statut === 429) return "trop de requetes, attends une minute.";
|
|
97
|
+
if (statut === 500)
|
|
98
|
+
return "la plateforme a refuse l'operation. Les creations de projet sont actuellement fermees : un projet qui n'existe pas deja ne peut pas etre cree.";
|
|
99
|
+
return "reponse inattendue.";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Trouve un projet SANS RIEN CREER.
|
|
104
|
+
*
|
|
105
|
+
* ⚠️ IL ACCEPTE LE NOM **OU** LE SLUG, et c'est le coeur du correctif. Le
|
|
106
|
+
* projet « présidence » a pour slug `presidence` : un assistant qui tape le
|
|
107
|
+
* slug, ou qui perd l'accent en chemin, ne trouvait rien. Ici on cherche dans
|
|
108
|
+
* les deux, puis on rend le nom EXACT tel qu'il est en base.
|
|
109
|
+
*/
|
|
110
|
+
async function trouver(name) {
|
|
111
|
+
const res = await fetch(API_LECTURE, {
|
|
112
|
+
headers: { authorization: `Bearer ${CLE}` },
|
|
113
|
+
});
|
|
114
|
+
if (!res.ok) {
|
|
115
|
+
const e = await lireEchec(res);
|
|
116
|
+
throw new Error(`Clicbase ${res.status} — ${expliquer(res.status)} ${e.detail}`);
|
|
117
|
+
}
|
|
118
|
+
const data = await res.json().catch(() => ({}));
|
|
119
|
+
const liste = Array.isArray(data.projects) ? data.projects : [];
|
|
120
|
+
const cible = String(name).trim().toLowerCase();
|
|
121
|
+
const p =
|
|
122
|
+
liste.find((x) => String(x.name ?? "").toLowerCase() === cible) ??
|
|
123
|
+
liste.find((x) => String(x.slug ?? "").toLowerCase() === cible);
|
|
124
|
+
if (!p) {
|
|
125
|
+
const noms = liste.map((x) => x.slug ?? x.name).filter(Boolean).join(", ");
|
|
126
|
+
throw new Error(
|
|
127
|
+
`Aucun projet « ${name} » dans le perimetre de cette cle.` +
|
|
128
|
+
(noms ? ` Projets visibles : ${noms}.` : " Cette cle ne voit aucun projet."),
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
return p;
|
|
132
|
+
}
|
|
133
|
+
|
|
41
134
|
// Provisionne (ou récupère, idempotent) un projet et renvoie ses infos.
|
|
135
|
+
//
|
|
136
|
+
// ⚠️ `creer: false` POUR TOUT OUTIL DE LECTURE. Cet appel est un POST sur
|
|
137
|
+
// l'endpoint de provisioning : idempotent quand le projet existe, CREATEUR
|
|
138
|
+
// sinon. `list_tables` survit au mode lecture seule et passait par ici : la
|
|
139
|
+
// promesse « aucune ecriture » etait donc fausse au niveau du transport, pendant
|
|
140
|
+
// que `create_database` disparaissait de la liste des outils. On verifie
|
|
141
|
+
// l'existence par une LECTURE d'abord, et on ne poste qu'un nom deja connu.
|
|
42
142
|
const cache = new Map();
|
|
43
|
-
async function resolve(name, domain) {
|
|
143
|
+
async function resolve(name, domain, { creer = true } = {}) {
|
|
44
144
|
if (cache.has(name)) return cache.get(name);
|
|
145
|
+
let nomExact = name;
|
|
146
|
+
if (!creer) nomExact = (await trouver(name)).name ?? name;
|
|
45
147
|
const res = await fetch(API, {
|
|
46
148
|
method: "POST",
|
|
47
149
|
headers: {
|
|
@@ -49,10 +151,13 @@ async function resolve(name, domain) {
|
|
|
49
151
|
// ⚠️ UNE CLE, PAS UN MOT DE PASSE. Voir l'en-tete du fichier.
|
|
50
152
|
authorization: `Bearer ${CLE}`,
|
|
51
153
|
},
|
|
52
|
-
body: JSON.stringify({ name, domain }),
|
|
154
|
+
body: JSON.stringify({ name: nomExact, domain }),
|
|
53
155
|
});
|
|
156
|
+
if (!res.ok) {
|
|
157
|
+
const e = await lireEchec(res);
|
|
158
|
+
throw new Error(`Clicbase ${res.status} — ${expliquer(res.status)} ${e.detail}`);
|
|
159
|
+
}
|
|
54
160
|
const data = await res.json().catch(() => ({}));
|
|
55
|
-
if (!res.ok) throw new Error(`Clicbase ${res.status}: ${JSON.stringify(data)}`);
|
|
56
161
|
const info = {
|
|
57
162
|
url: data.restApi?.url,
|
|
58
163
|
anonKey: data.restApi?.anonKey,
|
|
@@ -65,8 +170,8 @@ async function resolve(name, domain) {
|
|
|
65
170
|
}
|
|
66
171
|
|
|
67
172
|
// Appel d'un endpoint du projet avec la clé service.
|
|
68
|
-
async function call(name, path, method = "POST", body) {
|
|
69
|
-
const p = await resolve(name);
|
|
173
|
+
async function call(name, path, method = "POST", body, opts) {
|
|
174
|
+
const p = await resolve(name, undefined, opts);
|
|
70
175
|
if (!p.url || !p.serviceKey) throw new Error("Projet non résolu (clés manquantes).");
|
|
71
176
|
const res = await fetch(`${p.url}${path}`, {
|
|
72
177
|
method,
|
|
@@ -129,7 +234,20 @@ outils.tool(
|
|
|
129
234
|
"Liste les tables du schéma public d'un projet.",
|
|
130
235
|
{ name: z.string() },
|
|
131
236
|
async ({ name }) => {
|
|
132
|
-
try {
|
|
237
|
+
try {
|
|
238
|
+
// ⚠️ `creer: false` : un outil de lecture ne provisionne jamais.
|
|
239
|
+
return out(
|
|
240
|
+
await call(
|
|
241
|
+
name,
|
|
242
|
+
"/sql",
|
|
243
|
+
"POST",
|
|
244
|
+
{ query: "select table_name from information_schema.tables where table_schema='public' order by table_name" },
|
|
245
|
+
{ creer: false },
|
|
246
|
+
),
|
|
247
|
+
);
|
|
248
|
+
} catch (e) {
|
|
249
|
+
return fail(e);
|
|
250
|
+
}
|
|
133
251
|
},
|
|
134
252
|
);
|
|
135
253
|
|
|
@@ -156,6 +274,63 @@ outils.tool(
|
|
|
156
274
|
async ({ name, ...cfg }) => { try { return out(await call(name, "/email/config", "POST", cfg)); } catch (e) { return fail(e); } },
|
|
157
275
|
);
|
|
158
276
|
|
|
277
|
+
// Les fichiers d'un site heberge. `admin` cible l'API d'administration, la ou
|
|
278
|
+
// `call` parle a la base d'un projet : deux surfaces differentes.
|
|
279
|
+
async function admin(chemin, methode = "GET") {
|
|
280
|
+
const base = API_LECTURE.replace(/\/projects$/, "");
|
|
281
|
+
const res = await fetch(`${base}${chemin}`, {
|
|
282
|
+
method: methode,
|
|
283
|
+
headers: { authorization: `Bearer ${CLE}` },
|
|
284
|
+
});
|
|
285
|
+
if (!res.ok) {
|
|
286
|
+
const e = await lireEchec(res);
|
|
287
|
+
throw new Error(`Clicbase ${res.status} — ${expliquer(res.status)} ${e.detail}`);
|
|
288
|
+
}
|
|
289
|
+
return res.json();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
outils.tool(
|
|
293
|
+
"list_site_files",
|
|
294
|
+
"Liste les fichiers d'un site heberge (nom, dossier ou non, taille, date). `dossier` pour descendre d'un niveau. L'id du site vient de GET /sites.",
|
|
295
|
+
{ site_id: z.string(), dossier: z.string().optional() },
|
|
296
|
+
async ({ site_id, dossier }) => {
|
|
297
|
+
try {
|
|
298
|
+
const q = dossier ? `?dossier=${encodeURIComponent(dossier)}` : "";
|
|
299
|
+
return out(await admin(`/sites/${encodeURIComponent(site_id)}/files${q}`));
|
|
300
|
+
} catch (e) {
|
|
301
|
+
return fail(e);
|
|
302
|
+
}
|
|
303
|
+
},
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
outils.tool(
|
|
307
|
+
"read_site_file",
|
|
308
|
+
"Rend le contenu d'UN fichier d'un site heberge, en base64. Plafond 2 Mo. Les fichiers caches (.env, .git) sont refuses par le serveur, a tout niveau.",
|
|
309
|
+
{ site_id: z.string(), chemin: z.string() },
|
|
310
|
+
async ({ site_id, chemin }) => {
|
|
311
|
+
try {
|
|
312
|
+
const d = await admin(
|
|
313
|
+
`/sites/${encodeURIComponent(site_id)}/files?chemin=${encodeURIComponent(chemin)}`,
|
|
314
|
+
);
|
|
315
|
+
// ⚠️ ON DECODE ICI, ET ON PLAFONNE PLUS BAS QUE L'API. Rendre du base64 a
|
|
316
|
+
// un modele lui fait depenser son contexte a le decoder, souvent mal. Et
|
|
317
|
+
// 2 Mo de source dans une conversation ne servent personne : au-dela de
|
|
318
|
+
// 256 Ko on refuse en le disant, plutot que de noyer la session.
|
|
319
|
+
const brut = Buffer.from(String(d.contenu ?? ""), "base64");
|
|
320
|
+
if (brut.byteLength > 256 * 1024) {
|
|
321
|
+
return fail(
|
|
322
|
+
new Error(
|
|
323
|
+
`Fichier de ${brut.byteLength} octets : trop gros pour une conversation. Telecharge-le par le tableau de bord ou par SFTP.`,
|
|
324
|
+
),
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
return out({ chemin: d.chemin, octets: d.octets, contenu: brut.toString("utf8") });
|
|
328
|
+
} catch (e) {
|
|
329
|
+
return fail(e);
|
|
330
|
+
}
|
|
331
|
+
},
|
|
332
|
+
);
|
|
333
|
+
|
|
159
334
|
// ⚠️ LE MODE RESTREINT S'ANNONCE, SUR LA SORTIE D'ERREUR. Un serveur qui
|
|
160
335
|
// retire des outils en silence se decouvre en pleine session : l'assistant
|
|
161
336
|
// cherche une fonction qui devrait exister, ne la trouve pas, et conclut que le
|
package/package.json
CHANGED
package/portee.mjs
CHANGED
|
@@ -48,6 +48,14 @@ export const OUTILS = {
|
|
|
48
48
|
// restreint qui en a le plus besoin, puisqu'il ne reste alors que trois
|
|
49
49
|
// outils et qu'un assistant doit deviner le reste.
|
|
50
50
|
clicbase_conventions: "lecture",
|
|
51
|
+
// --- fichiers d'un site heberge ---
|
|
52
|
+
// ⚠️ LISTER EST UNE LECTURE, LIRE EST UN SECRET. Le nom et la taille d'un
|
|
53
|
+
// fichier revelent une structure ; son CONTENU peut porter des identifiants,
|
|
54
|
+
// dans un config.php ou un settings.js. Meme raisonnement que
|
|
55
|
+
// get_credentials : il ne modifie rien, et il rend quand meme les clefs de
|
|
56
|
+
// la maison. Les fichiers caches, eux, sont deja impossibles cote serveur.
|
|
57
|
+
list_site_files: "lecture",
|
|
58
|
+
read_site_file: "secret",
|
|
51
59
|
};
|
|
52
60
|
|
|
53
61
|
/** Ce qu'on garde en lecture seule. */
|
package/savoir.mjs
CHANGED
|
@@ -35,6 +35,8 @@ SIX PIÈGES QUI NE LÈVENT AUCUNE ERREUR. Ils répondent tous « 200 OK ».
|
|
|
35
35
|
5. Une fonction Edge renvoie \`{ status, body }\`, jamais \`new Response(...)\`.
|
|
36
36
|
6. Deux authentifications sans rapport coexistent. Celle du PROJET (tes utilisateurs finaux) vit dans le schéma \`auth\` de cette base et alimente \`auth.uid()\` dans les policies. Celle de la PLATEFORME (le compte Clicbase) ne s'écrit jamais dans une policy.
|
|
37
37
|
|
|
38
|
+
CE QUI N'EXISTE PAS, ET QUE DEUX ASSISTANTS ONT DEJA INVENTE. Il n'y a AUCUN registre Docker : ni \`docker login\`, ni \`docker pull\`, ni \`registry.*.clicbase.com\`. Ne devine aucun nom d'hote. Les fichiers d'un site heberge se lisent par \`list_site_files\` et \`read_site_file\`, et s'ecrivent par POST /sites/<id>/files. Inutile de reclamer un mot de passe SFTP pour cela : le SFTP reste utile pour un transfert volumineux, pas pour consulter. Les fichiers CACHES sont refuses a tout niveau, \`.env\` et \`.git\` compris : ils ne peuvent ni etre deposes ni etre lus. \`read_site_file\` est absent en lecture seule, parce qu'un source peut porter des identifiants dans un fichier non cache.
|
|
39
|
+
|
|
38
40
|
APPELS REST : en-têtes \`apikey\` ET \`Authorization: Bearer\`, la même clé dans les deux. Filtres dans l'URL, façon PostgREST : \`?select=id,title&status=eq.published&order=created_at.desc\`.
|
|
39
41
|
|
|
40
42
|
DEUX CLÉS À NE PAS CONFONDRE. \`anon\` est publique et soumise à la RLS, elle va dans le navigateur. \`service\` CONTOURNE la RLS et voit tout : serveur uniquement, jamais dans un front, jamais dans un dépôt.
|