@clicbase/mcp 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 +146 -0
- package/clicbase-db-mcp.mjs +193 -0
- package/clicbase-mcp.mjs +157 -0
- package/package.json +43 -0
- package/portee.mjs +108 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Clicbase
|
|
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
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# Serveur MCP Clicbase (tout-en-un)
|
|
2
|
+
|
|
3
|
+
Donne à Claude (ou tout client MCP) de quoi **provisionner ET administrer** une base
|
|
4
|
+
Clicbase, avec une **clé API** `cbk_…` générée depuis le tableau de bord.
|
|
5
|
+
|
|
6
|
+
> ⚠️ Cette phrase annonçait « uniquement avec un compte Clicbase (email + mot de
|
|
7
|
+
> passe) » jusqu'au 2026-09-17, alors que le code n'accepte plus que la clé
|
|
8
|
+
> depuis le 2026-09-13. La section « Pourquoi une clé et pas un mot de passe »,
|
|
9
|
+
> plus bas, disait déjà le contraire : personne ne lit la page entière avant de
|
|
10
|
+
> se lancer, et une première ligne fausse envoie chercher au mauvais endroit.
|
|
11
|
+
|
|
12
|
+
Outils :
|
|
13
|
+
|
|
14
|
+
- `create_database` — crée/récupère une base pour un site (renvoie connexion Postgres + URL REST + clés anon/service).
|
|
15
|
+
- `get_credentials` — récupère les identifiants d'un projet existant.
|
|
16
|
+
- `run_sql` — exécute du SQL (tables, RLS, policies…) sur un projet.
|
|
17
|
+
- `list_tables` — liste les tables d'un projet.
|
|
18
|
+
- `enable_realtime` — active le temps réel sur une table.
|
|
19
|
+
- `set_oauth_provider` — configure Google OAuth.
|
|
20
|
+
- `set_email_smtp` — configure un SMTP (sinon SMTP interne Clicbase par défaut).
|
|
21
|
+
|
|
22
|
+
## Lecture seule
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
claude mcp add clicbase \
|
|
26
|
+
-e CLICBASE_API_KEY=cbk_... \
|
|
27
|
+
-e CLICBASE_READ_ONLY=1 \
|
|
28
|
+
-- node /chemin/absolu/vers/mcp/clicbase-mcp.mjs
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
En lecture seule, les outils qui écrivent ne sont **pas refusés : ils sont
|
|
32
|
+
absents**. Un outil présent qui répond « interdit » laisse l'assistant croire
|
|
33
|
+
qu'un chemin existe, donc essayer, reformuler, insister. Un outil qui n'est pas
|
|
34
|
+
dans la liste n'est jamais demandé.
|
|
35
|
+
|
|
36
|
+
Ce qui reste : `list_tables`, `describe_table`, `get_oauth_provider`.
|
|
37
|
+
Ce qui part : `run_sql`, `create_database`, `enable_realtime`, `enable_rls`,
|
|
38
|
+
`create_policy`, `set_oauth_provider`, `set_email_smtp`, et **`get_credentials`**.
|
|
39
|
+
|
|
40
|
+
> `get_credentials` ne modifie rien, et il part quand même. Il rend la clé
|
|
41
|
+
> `service` du projet, laquelle ouvre le SQL complet et contourne la RLS. Le
|
|
42
|
+
> garder reviendrait à retirer `run_sql` d'une main et à livrer de quoi le
|
|
43
|
+
> refaire de l'autre.
|
|
44
|
+
|
|
45
|
+
Un outil ajouté plus tard sans être classé dans `portee.mjs` est traité comme
|
|
46
|
+
une **écriture** : il disparaît du mode restreint au lieu d'y être admis. Un
|
|
47
|
+
oubli coûte alors une fonction manquante, que l'on remarque ; l'inverse aurait
|
|
48
|
+
coûté une écriture ouverte dans un mode qui promet de ne pas écrire.
|
|
49
|
+
|
|
50
|
+
## Quand l'utiliser
|
|
51
|
+
|
|
52
|
+
`run_sql` exécute du SQL arbitraire avec les droits du propriétaire de la base,
|
|
53
|
+
et la clé service **contourne la RLS** : un `SELECT` rend toutes les données
|
|
54
|
+
personnelles du projet en clair, et elles entrent dans le contexte du modèle.
|
|
55
|
+
Six risques n'ont aucune parade côté serveur, dont l'impossibilité de
|
|
56
|
+
distinguer un `DROP TABLE` prévu d'un `DROP TABLE` accidentel.
|
|
57
|
+
|
|
58
|
+
En clair :
|
|
59
|
+
|
|
60
|
+
| | mode complet | lecture seule |
|
|
61
|
+
| --- | --- | --- |
|
|
62
|
+
| Ta propre plateforme, sous tes yeux | oui | — |
|
|
63
|
+
| Un agent autonome | non | oui |
|
|
64
|
+
| La base d'un client | non | oui |
|
|
65
|
+
|
|
66
|
+
## Installation
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
claude mcp add clicbase \
|
|
70
|
+
-e CLICBASE_API_KEY=cbk_... \
|
|
71
|
+
-e CLICBASE_READ_ONLY=1 \
|
|
72
|
+
-- npx -y @clicbase/mcp
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
> ⚠️ `CLICBASE_READ_ONLY=1` est volontairement dans la commande d'installation.
|
|
76
|
+
> C'est le mode à conseiller par défaut : il retire `run_sql`, la création de
|
|
77
|
+
> projet et la clé `service`. Retire cette ligne seulement quand tu sais
|
|
78
|
+
> pourquoi, et lis la section « Quand l'utiliser » avant.
|
|
79
|
+
|
|
80
|
+
### Depuis le dépôt
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
cd mcp
|
|
85
|
+
npm install
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Configuration dans Claude Code
|
|
89
|
+
|
|
90
|
+
Le plus simple (depuis le dossier du site à connecter) :
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
claude mcp add clicbase -e CLICBASE_API_KEY=cbk_... -- node /chemin/absolu/vers/mcp/clicbase-mcp.mjs
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Ou à la main dans la config MCP (`~/.claude.json` / `claude_desktop_config.json`) :
|
|
97
|
+
|
|
98
|
+
```json
|
|
99
|
+
{
|
|
100
|
+
"mcpServers": {
|
|
101
|
+
"clicbase": {
|
|
102
|
+
"command": "node",
|
|
103
|
+
"args": ["/chemin/absolu/vers/mcp/clicbase-mcp.mjs"],
|
|
104
|
+
"env": {
|
|
105
|
+
"CLICBASE_API_KEY": "cbk_..."
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Utilisation : « Claude fait tout »
|
|
113
|
+
|
|
114
|
+
Dans la session Claude du site (ex. exoskool.com), une seule consigne suffit :
|
|
115
|
+
|
|
116
|
+
> « Connecte ce site à Clicbase via le MCP clicbase. Crée une base `exoskool`
|
|
117
|
+
> (domaine `exoskool.com`), crée le schéma dont le site a besoin (tables + RLS),
|
|
118
|
+
> puis branche le front sur l'API REST (clé anon) et écris les variables dans `.env`. »
|
|
119
|
+
|
|
120
|
+
Claude appellera `create_database`, puis `run_sql` pour le schéma/RLS, récupèrera
|
|
121
|
+
les clés via `get_credentials`, et câblera le site. Aucune clé à copier-coller à la main.
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
## Pourquoi une clé et pas un mot de passe
|
|
125
|
+
|
|
126
|
+
Ce serveur envoyait `ROROUIRA_EMAIL` + `ROROUIRA_PASSWORD`. Changé le
|
|
127
|
+
2026-09-13.
|
|
128
|
+
|
|
129
|
+
Un mot de passe est une **identité** : il ouvre le compte entier, doit vivre
|
|
130
|
+
en clair dans la configuration, et ne laisse aucune trace distinguable. Pour le
|
|
131
|
+
révoquer, il faut le changer partout où il a été collé, sans jamais savoir si
|
|
132
|
+
on a tout trouvé.
|
|
133
|
+
|
|
134
|
+
Une clé `cbk_…` est une **permission** :
|
|
135
|
+
|
|
136
|
+
| | mot de passe | clé API |
|
|
137
|
+
| --- | --- | --- |
|
|
138
|
+
| Portée | tout le compte | `vps`, `docker` ou granulaire |
|
|
139
|
+
| Expiration | aucune | `expiresAt`, automatique |
|
|
140
|
+
| Révocation | changer le mot de passe partout | un clic |
|
|
141
|
+
| Stockage serveur | — | seulement un `sha256` |
|
|
142
|
+
| Journal | indistinguable de toi | chaque appel, avec son `keyId` |
|
|
143
|
+
|
|
144
|
+
La clé se génère depuis le tableau de bord, menu **VPS** ou **Docker** ›
|
|
145
|
+
**Clés API**. Elle n'est affichée qu'une fois : le serveur n'en garde que
|
|
146
|
+
l'empreinte.
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Serveur MCP d'administration d'UNE base Clicbase (scopé à un projet).
|
|
3
|
+
// Emballe l'endpoint /sql (clé service) en outils MCP.
|
|
4
|
+
// Env requis : CLICBASE_DB_URL (ex. https://clicbase.com/db/mon-projet)
|
|
5
|
+
// CLICBASE_SERVICE_KEY (clé service du projet — côté outil uniquement)
|
|
6
|
+
//
|
|
7
|
+
// ⚠️ LES ANCIENS NOMS RESTENT ACCEPTÉS, EN SECOND. `ROROUIRA_DB_URL` et
|
|
8
|
+
// `ROROUIRA_SERVICE_KEY` continuent de fonctionner : une configuration déjà
|
|
9
|
+
// posée chez quelqu'un ne doit pas cesser de marcher parce que nous avons
|
|
10
|
+
// renommé la maison. Ils disparaîtront quand plus personne ne les utilisera.
|
|
11
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
12
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
import { filtre, lectureSeule } from "./portee.mjs";
|
|
15
|
+
|
|
16
|
+
const BASE = process.env.CLICBASE_DB_URL ?? process.env.ROROUIRA_DB_URL;
|
|
17
|
+
const KEY = process.env.CLICBASE_SERVICE_KEY ?? process.env.ROROUIRA_SERVICE_KEY;
|
|
18
|
+
if (!BASE || !KEY) {
|
|
19
|
+
console.error(
|
|
20
|
+
"CLICBASE_DB_URL et CLICBASE_SERVICE_KEY requis. " +
|
|
21
|
+
"Exemple : CLICBASE_DB_URL=https://clicbase.com/db/mon-projet",
|
|
22
|
+
);
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const ident = (s) => '"' + String(s).replace(/"/g, '""') + '"';
|
|
27
|
+
const lit = (s) => "'" + String(s).replace(/'/g, "''") + "'";
|
|
28
|
+
|
|
29
|
+
async function sql(query) {
|
|
30
|
+
const res = await fetch(`${BASE}/sql`, {
|
|
31
|
+
method: "POST",
|
|
32
|
+
headers: { "content-type": "application/json", Authorization: `Bearer ${KEY}` },
|
|
33
|
+
body: JSON.stringify({ query }),
|
|
34
|
+
});
|
|
35
|
+
const data = await res.json().catch(() => ({}));
|
|
36
|
+
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
37
|
+
return data;
|
|
38
|
+
}
|
|
39
|
+
// Appel générique d'un endpoint du projet (autre que /sql), avec la clé service.
|
|
40
|
+
async function api(path, method = "GET", body) {
|
|
41
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
42
|
+
method,
|
|
43
|
+
headers: { "content-type": "application/json", Authorization: `Bearer ${KEY}` },
|
|
44
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
45
|
+
});
|
|
46
|
+
const data = await res.json().catch(() => ({}));
|
|
47
|
+
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
48
|
+
return data;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const out = (d) => ({ content: [{ type: "text", text: JSON.stringify(d, null, 2) }] });
|
|
52
|
+
const fail = (e) => ({ isError: true, content: [{ type: "text", text: String(e?.message ?? e) }] });
|
|
53
|
+
|
|
54
|
+
const server = new McpServer({ name: "clicbase-db", version: "1.0.0" });
|
|
55
|
+
|
|
56
|
+
// ⚠️ EN LECTURE SEULE, LES OUTILS QUI ECRIVENT NE SONT PAS ENREGISTRES. Voir
|
|
57
|
+
// portee.mjs : un outil absent n'est jamais demande, un outil present qui
|
|
58
|
+
// refuse invite a insister. `outils.tool(...)` remplace l'appel direct.
|
|
59
|
+
const SEULEMENT_LECTURE = lectureSeule();
|
|
60
|
+
const outils = filtre(server, SEULEMENT_LECTURE);
|
|
61
|
+
|
|
62
|
+
outils.tool(
|
|
63
|
+
"run_sql",
|
|
64
|
+
"Exécute du SQL (DDL/DML/RLS) sur la base. Après un changement de schéma, termine par: NOTIFY pgrst, 'reload schema';",
|
|
65
|
+
{ query: z.string() },
|
|
66
|
+
async ({ query }) => {
|
|
67
|
+
try {
|
|
68
|
+
return out(await sql(query));
|
|
69
|
+
} catch (e) {
|
|
70
|
+
return fail(e);
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
outils.tool("list_tables", "Liste les tables du schéma public.", {}, async () => {
|
|
76
|
+
try {
|
|
77
|
+
return out(
|
|
78
|
+
await sql(
|
|
79
|
+
"select table_name from information_schema.tables where table_schema='public' order by table_name",
|
|
80
|
+
),
|
|
81
|
+
);
|
|
82
|
+
} catch (e) {
|
|
83
|
+
return fail(e);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
outils.tool(
|
|
88
|
+
"describe_table",
|
|
89
|
+
"Décrit les colonnes d'une table (nom, type, nullable, défaut).",
|
|
90
|
+
{ table: z.string() },
|
|
91
|
+
async ({ table }) => {
|
|
92
|
+
try {
|
|
93
|
+
return out(
|
|
94
|
+
await sql(
|
|
95
|
+
`select column_name, data_type, is_nullable, column_default from information_schema.columns where table_schema='public' and table_name=${lit(table)} order by ordinal_position`,
|
|
96
|
+
),
|
|
97
|
+
);
|
|
98
|
+
} catch (e) {
|
|
99
|
+
return fail(e);
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
outils.tool(
|
|
105
|
+
"enable_rls",
|
|
106
|
+
"Active la Row-Level Security sur une table.",
|
|
107
|
+
{ table: z.string() },
|
|
108
|
+
async ({ table }) => {
|
|
109
|
+
try {
|
|
110
|
+
return out(
|
|
111
|
+
await sql(
|
|
112
|
+
`alter table ${ident(table)} enable row level security; notify pgrst, 'reload schema';`,
|
|
113
|
+
),
|
|
114
|
+
);
|
|
115
|
+
} catch (e) {
|
|
116
|
+
return fail(e);
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
outils.tool(
|
|
122
|
+
"create_policy",
|
|
123
|
+
"Crée (ou remplace) une policy RLS. using/check sont des expressions SQL (ex: \"auth.uid() is not null\", \"user_id = auth.uid()\").",
|
|
124
|
+
{
|
|
125
|
+
table: z.string(),
|
|
126
|
+
name: z.string(),
|
|
127
|
+
command: z.enum(["select", "insert", "update", "delete", "all"]),
|
|
128
|
+
using: z.string().optional(),
|
|
129
|
+
check: z.string().optional(),
|
|
130
|
+
},
|
|
131
|
+
async ({ table, name, command, using, check }) => {
|
|
132
|
+
try {
|
|
133
|
+
let s = `drop policy if exists ${ident(name)} on ${ident(table)}; create policy ${ident(name)} on ${ident(table)} for ${command}`;
|
|
134
|
+
if (using) s += ` using (${using})`;
|
|
135
|
+
if (check) s += ` with check (${check})`;
|
|
136
|
+
s += `; notify pgrst, 'reload schema';`;
|
|
137
|
+
return out(await sql(s));
|
|
138
|
+
} catch (e) {
|
|
139
|
+
return fail(e);
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
outils.tool(
|
|
145
|
+
"get_oauth_provider",
|
|
146
|
+
"Donne le statut d'un provider OAuth (ex. google) : activé ou non.",
|
|
147
|
+
{ provider: z.enum(["google"]) },
|
|
148
|
+
async ({ provider }) => {
|
|
149
|
+
try {
|
|
150
|
+
return out(await api(`/auth/providers/${provider}`));
|
|
151
|
+
} catch (e) {
|
|
152
|
+
return fail(e);
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
outils.tool(
|
|
158
|
+
"set_oauth_provider",
|
|
159
|
+
"Configure un provider OAuth (ex. google) avec son client_id et son client_secret (chiffré côté serveur). L'URI de redirection à déclarer chez le fournisseur est: <ROROUIRA_DB_URL>/auth/callback/<provider>.",
|
|
160
|
+
{
|
|
161
|
+
provider: z.enum(["google"]),
|
|
162
|
+
client_id: z.string(),
|
|
163
|
+
client_secret: z.string(),
|
|
164
|
+
},
|
|
165
|
+
async ({ provider, client_id, client_secret }) => {
|
|
166
|
+
try {
|
|
167
|
+
return out(
|
|
168
|
+
await api(`/auth/providers/${provider}`, "POST", {
|
|
169
|
+
client_id,
|
|
170
|
+
client_secret,
|
|
171
|
+
}),
|
|
172
|
+
);
|
|
173
|
+
} catch (e) {
|
|
174
|
+
return fail(e);
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
// ⚠️ LE MODE RESTREINT S'ANNONCE, SUR LA SORTIE D'ERREUR. Un serveur qui
|
|
180
|
+
// retire des outils en silence se decouvre en pleine session : l'assistant
|
|
181
|
+
// cherche une fonction qui devrait exister, ne la trouve pas, et conclut que le
|
|
182
|
+
// serveur est casse. Deux lignes evitent cette demi-heure. La sortie STANDARD
|
|
183
|
+
// est reservee au protocole MCP : y ecrire casserait la conversation.
|
|
184
|
+
if (SEULEMENT_LECTURE) {
|
|
185
|
+
console.error(
|
|
186
|
+
`[clicbase-mcp] LECTURE SEULE. Outils retires : ${outils.retires.join(", ") || "aucun"}.`,
|
|
187
|
+
);
|
|
188
|
+
console.error(
|
|
189
|
+
"[clicbase-mcp] Pour tout ouvrir : retirer CLICBASE_READ_ONLY de la configuration.",
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
await server.connect(new StdioServerTransport());
|
package/clicbase-mcp.mjs
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// MCP Clicbase "tout-en-un" : Claude provisionne ET administre une base.
|
|
3
|
+
//
|
|
4
|
+
// ⚠️ AUTHENTIFICATION PAR CLE API, PLUS PAR MOT DE PASSE. Ce fichier envoyait
|
|
5
|
+
// ROROUIRA_EMAIL + ROROUIRA_PASSWORD, ce qui donnait a un programme l'acces au
|
|
6
|
+
// COMPTE ENTIER : pas de portee, pas d'expiration, pas de plafond, aucune
|
|
7
|
+
// trace distinguable dans le journal, et une revocation qui oblige a changer
|
|
8
|
+
// le mot de passe partout ou il a ete colle.
|
|
9
|
+
//
|
|
10
|
+
// Une cle `cbk_…` est une PERMISSION, pas une identite : portee, expiration,
|
|
11
|
+
// revocation d'un clic, et chaque appel journalise avec son keyId. Le secret
|
|
12
|
+
// n'est jamais stocke cote serveur, seulement son sha256.
|
|
13
|
+
//
|
|
14
|
+
// C'etait aussi le seul endroit du depot qui contredisait la regle « Teiki ne
|
|
15
|
+
// se connecte jamais par mot de passe ». Corrige le 2026-09-13.
|
|
16
|
+
//
|
|
17
|
+
// export CLICBASE_API_KEY=cbk_... (genere depuis le tableau de bord)
|
|
18
|
+
// L'API de provisioning est idempotente -> on l'utilise aussi pour résoudre
|
|
19
|
+
// dynamiquement l'URL + la clé service d'un projet (par nom).
|
|
20
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
21
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
22
|
+
import { z } from "zod";
|
|
23
|
+
import { filtre, lectureSeule } from "./portee.mjs";
|
|
24
|
+
|
|
25
|
+
const API = process.env.CLICBASE_API ?? process.env.ROROUIRA_API ?? "https://clicbase.com/api/v1/databases";
|
|
26
|
+
|
|
27
|
+
/** La cle, lue une fois. Absente, on le dit tout de suite plutot que de
|
|
28
|
+
* laisser chaque appel echouer en 401 sans expliquer ce qui manque. */
|
|
29
|
+
const CLE = process.env.CLICBASE_API_KEY;
|
|
30
|
+
if (!CLE) {
|
|
31
|
+
console.error(
|
|
32
|
+
"CLICBASE_API_KEY manquante. Genere une cle depuis le tableau de bord " +
|
|
33
|
+
"(menu VPS ou Docker > Cles API), puis : export CLICBASE_API_KEY=cbk_...",
|
|
34
|
+
);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
const out = (d) => ({ content: [{ type: "text", text: typeof d === "string" ? d : JSON.stringify(d, null, 2) }] });
|
|
38
|
+
const fail = (e) => ({ isError: true, content: [{ type: "text", text: String(e?.message ?? e) }] });
|
|
39
|
+
|
|
40
|
+
// Provisionne (ou récupère, idempotent) un projet et renvoie ses infos.
|
|
41
|
+
const cache = new Map();
|
|
42
|
+
async function resolve(name, domain) {
|
|
43
|
+
if (cache.has(name)) return cache.get(name);
|
|
44
|
+
const res = await fetch(API, {
|
|
45
|
+
method: "POST",
|
|
46
|
+
headers: {
|
|
47
|
+
"content-type": "application/json",
|
|
48
|
+
// ⚠️ UNE CLE, PAS UN MOT DE PASSE. Voir l'en-tete du fichier.
|
|
49
|
+
authorization: `Bearer ${CLE}`,
|
|
50
|
+
},
|
|
51
|
+
body: JSON.stringify({ name, domain }),
|
|
52
|
+
});
|
|
53
|
+
const data = await res.json().catch(() => ({}));
|
|
54
|
+
if (!res.ok) throw new Error(`Clicbase ${res.status}: ${JSON.stringify(data)}`);
|
|
55
|
+
const info = {
|
|
56
|
+
url: data.restApi?.url,
|
|
57
|
+
anonKey: data.restApi?.anonKey,
|
|
58
|
+
serviceKey: data.restApi?.serviceKey,
|
|
59
|
+
connectionString: data.connectionString,
|
|
60
|
+
raw: data,
|
|
61
|
+
};
|
|
62
|
+
cache.set(name, info);
|
|
63
|
+
return info;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Appel d'un endpoint du projet avec la clé service.
|
|
67
|
+
async function call(name, path, method = "POST", body) {
|
|
68
|
+
const p = await resolve(name);
|
|
69
|
+
if (!p.url || !p.serviceKey) throw new Error("Projet non résolu (clés manquantes).");
|
|
70
|
+
const res = await fetch(`${p.url}${path}`, {
|
|
71
|
+
method,
|
|
72
|
+
headers: { "content-type": "application/json", Authorization: `Bearer ${p.serviceKey}` },
|
|
73
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
74
|
+
});
|
|
75
|
+
const data = await res.json().catch(() => ({}));
|
|
76
|
+
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
77
|
+
return data;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const server = new McpServer({ name: "clicbase", version: "2.0.0" });
|
|
81
|
+
|
|
82
|
+
// ⚠️ EN LECTURE SEULE, LES OUTILS QUI ECRIVENT NE SONT PAS ENREGISTRES. Voir
|
|
83
|
+
// portee.mjs : un outil absent n'est jamais demande, un outil present qui
|
|
84
|
+
// refuse invite a insister. `outils.tool(...)` remplace l'appel direct.
|
|
85
|
+
const SEULEMENT_LECTURE = lectureSeule();
|
|
86
|
+
const outils = filtre(server, SEULEMENT_LECTURE);
|
|
87
|
+
|
|
88
|
+
outils.tool(
|
|
89
|
+
"create_database",
|
|
90
|
+
"Crée (ou récupère, idempotent) une base Clicbase pour un site. Renvoie la chaîne de connexion PostgreSQL, l'URL de l'API REST et les clés anon/service.",
|
|
91
|
+
{ name: z.string().describe("Nom du projet, ex. exoskool"), domain: z.string().optional().describe("Domaine, ex. exoskool.com") },
|
|
92
|
+
async ({ name, domain }) => {
|
|
93
|
+
try { cache.delete(name); return out((await resolve(name, domain)).raw); } catch (e) { return fail(e); }
|
|
94
|
+
},
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
outils.tool(
|
|
98
|
+
"get_credentials",
|
|
99
|
+
"Renvoie les identifiants d'un projet existant (connexion Postgres + URL REST + clés anon/service) pour brancher le site.",
|
|
100
|
+
{ name: z.string() },
|
|
101
|
+
async ({ name }) => { try { return out((await resolve(name)).raw); } catch (e) { return fail(e); } },
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
outils.tool(
|
|
105
|
+
"run_sql",
|
|
106
|
+
"Exécute du SQL (DDL/DML/RLS) sur la base d'un projet. Pour créer des tables, policies RLS, etc. Termine par: NOTIFY pgrst, 'reload schema'; après un changement de schéma.",
|
|
107
|
+
{ name: z.string().describe("Nom du projet"), query: z.string() },
|
|
108
|
+
async ({ name, query }) => { try { return out(await call(name, "/sql", "POST", { query })); } catch (e) { return fail(e); } },
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
outils.tool(
|
|
112
|
+
"list_tables",
|
|
113
|
+
"Liste les tables du schéma public d'un projet.",
|
|
114
|
+
{ name: z.string() },
|
|
115
|
+
async ({ name }) => {
|
|
116
|
+
try { return out(await call(name, "/sql", "POST", { query: "select table_name from information_schema.tables where table_schema='public' order by table_name" })); } catch (e) { return fail(e); }
|
|
117
|
+
},
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
outils.tool(
|
|
121
|
+
"enable_realtime",
|
|
122
|
+
"Active le temps réel (WebSocket) sur une table d'un projet.",
|
|
123
|
+
{ name: z.string(), table: z.string() },
|
|
124
|
+
async ({ name, table }) => { try { return out(await call(name, "/realtime/enable", "POST", { table })); } catch (e) { return fail(e); } },
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
outils.tool(
|
|
128
|
+
"set_oauth_provider",
|
|
129
|
+
"Configure un provider OAuth (ex. google) d'un projet. URI de redirection à déclarer chez le fournisseur : <url REST du projet>/auth/callback/<provider>.",
|
|
130
|
+
{ name: z.string(), provider: z.enum(["google"]), client_id: z.string(), client_secret: z.string() },
|
|
131
|
+
async ({ name, provider, client_id, client_secret }) => {
|
|
132
|
+
try { return out(await call(name, `/auth/providers/${provider}`, "POST", { client_id, client_secret })); } catch (e) { return fail(e); }
|
|
133
|
+
},
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
outils.tool(
|
|
137
|
+
"set_email_smtp",
|
|
138
|
+
"Configure le SMTP d'un projet (sinon le SMTP interne Clicbase est utilisé par défaut).",
|
|
139
|
+
{ name: z.string(), host: z.string(), port: z.number().optional(), secure: z.boolean().optional(), username: z.string().optional(), password: z.string().optional(), sender_email: z.string(), sender_name: z.string().optional() },
|
|
140
|
+
async ({ name, ...cfg }) => { try { return out(await call(name, "/email/config", "POST", cfg)); } catch (e) { return fail(e); } },
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
// ⚠️ LE MODE RESTREINT S'ANNONCE, SUR LA SORTIE D'ERREUR. Un serveur qui
|
|
144
|
+
// retire des outils en silence se decouvre en pleine session : l'assistant
|
|
145
|
+
// cherche une fonction qui devrait exister, ne la trouve pas, et conclut que le
|
|
146
|
+
// serveur est casse. Deux lignes evitent cette demi-heure. La sortie STANDARD
|
|
147
|
+
// est reservee au protocole MCP : y ecrire casserait la conversation.
|
|
148
|
+
if (SEULEMENT_LECTURE) {
|
|
149
|
+
console.error(
|
|
150
|
+
`[clicbase-mcp] LECTURE SEULE. Outils retires : ${outils.retires.join(", ") || "aucun"}.`,
|
|
151
|
+
);
|
|
152
|
+
console.error(
|
|
153
|
+
"[clicbase-mcp] Pour tout ouvrir : retirer CLICBASE_READ_ONLY de la configuration.",
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
await server.connect(new StdioServerTransport());
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@clicbase/mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"bin": {
|
|
6
|
+
"clicbase-mcp": "clicbase-mcp.mjs",
|
|
7
|
+
"clicbase-db-mcp": "clicbase-db-mcp.mjs"
|
|
8
|
+
},
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
11
|
+
"zod": "^3.23.0"
|
|
12
|
+
},
|
|
13
|
+
"description": "Serveur MCP pour Clicbase : provisionner et administrer une base PostgreSQL managee depuis un assistant.",
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"homepage": "https://clicbase.com",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/clicbase/mcp.git"
|
|
19
|
+
},
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/clicbase/mcp/issues"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"mcp",
|
|
25
|
+
"model-context-protocol",
|
|
26
|
+
"clicbase",
|
|
27
|
+
"postgres",
|
|
28
|
+
"baas"
|
|
29
|
+
],
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=20"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"clicbase-mcp.mjs",
|
|
35
|
+
"clicbase-db-mcp.mjs",
|
|
36
|
+
"portee.mjs",
|
|
37
|
+
"README.md",
|
|
38
|
+
"LICENSE"
|
|
39
|
+
],
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/portee.mjs
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// LA PORTÉE DES SERVEURS MCP : ce qu'une IA reçoit, et ce qu'elle ne voit pas.
|
|
2
|
+
//
|
|
3
|
+
// Demande de Teiki le 2026-09-17, après examen de ce que fait Supabase : leur
|
|
4
|
+
// serveur se configure par `read_only=true`, `project_ref` et `features`, et
|
|
5
|
+
// leur première ligne d'installation est un avertissement de sécurité.
|
|
6
|
+
//
|
|
7
|
+
// ⚠️ EN LECTURE SEULE, LES OUTILS QUI ÉCRIVENT NE SONT PAS REFUSÉS : ILS SONT
|
|
8
|
+
// ABSENTS. C'est la seule parade qui tienne. Un outil présent mais qui répond
|
|
9
|
+
// « interdit » laisse l'assistant croire qu'il existe un chemin, donc essayer,
|
|
10
|
+
// reformuler, insister. Un outil qui n'est pas dans la liste n'est jamais
|
|
11
|
+
// demandé : on ne réclame pas ce qu'on ne voit pas.
|
|
12
|
+
//
|
|
13
|
+
// ⚠️ ON NE FILTRE PAS L'INTENTION, ON RETIRE L'OUTIL. `DROP TABLE` est du DDL
|
|
14
|
+
// valide, et le DDL est la raison d'être de `run_sql` : aucune liste de mots
|
|
15
|
+
// interdits ne distingue « supprime l'ancienne table comme prévu » de
|
|
16
|
+
// « supprime la mauvaise ». La seule frontière qui tient passe par la présence
|
|
17
|
+
// de l'outil, pas par l'analyse de ce qu'on lui demande.
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Ce que chaque outil fait, du point de vue du risque.
|
|
21
|
+
*
|
|
22
|
+
* · `lecture` : ne modifie rien et ne rend aucun secret.
|
|
23
|
+
* · `ecriture` : modifie la base, la configuration ou crée une ressource.
|
|
24
|
+
* · `secret` : ne modifie rien, mais REND UNE CLÉ qui, elle, permet tout.
|
|
25
|
+
*
|
|
26
|
+
* ⚠️ `secret` EXISTE POUR `get_credentials`, ET CE N'EST PAS UN EXCÈS DE ZÈLE.
|
|
27
|
+
* Il ne modifie rien, donc il a l'air d'une lecture. Mais il rend la clé
|
|
28
|
+
* `service` du projet, laquelle ouvre le SQL complet et contourne la RLS.
|
|
29
|
+
* Le laisser en mode lecture seule reviendrait à retirer `run_sql` d'une main
|
|
30
|
+
* et à livrer de quoi le refaire de l'autre.
|
|
31
|
+
*/
|
|
32
|
+
export const OUTILS = {
|
|
33
|
+
// --- serveur « tout-en-un » ---
|
|
34
|
+
create_database: "ecriture",
|
|
35
|
+
get_credentials: "secret",
|
|
36
|
+
run_sql: "ecriture",
|
|
37
|
+
list_tables: "lecture",
|
|
38
|
+
enable_realtime: "ecriture",
|
|
39
|
+
set_oauth_provider: "ecriture",
|
|
40
|
+
set_email_smtp: "ecriture",
|
|
41
|
+
// --- serveur « base d'un projet » ---
|
|
42
|
+
describe_table: "lecture",
|
|
43
|
+
enable_rls: "ecriture",
|
|
44
|
+
create_policy: "ecriture",
|
|
45
|
+
get_oauth_provider: "lecture",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Ce qu'on garde en lecture seule. */
|
|
49
|
+
const PERMIS_EN_LECTURE = new Set(["lecture"]);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Le serveur tourne-t-il en lecture seule ?
|
|
53
|
+
*
|
|
54
|
+
* ⚠️ TOUTE VALEUR AUTRE QUE « 0 », « false » OU VIDE ACTIVE LE MODE. La
|
|
55
|
+
* variable est là pour fermer : quelqu'un qui écrit `CLICBASE_READ_ONLY=oui`
|
|
56
|
+
* veut manifestement fermer, et un jeu de valeurs trop strict lui ouvrirait
|
|
57
|
+
* tout en silence. On ferme au moindre doute, jamais l'inverse.
|
|
58
|
+
*
|
|
59
|
+
* ⚠️ LE PARAMÈTRE EST ANNOTÉ LARGEMENT, ET PAS LAISSÉ À L'INFÉRENCE. Sans cette
|
|
60
|
+
* ligne, TypeScript déduit le type de la valeur par défaut, donc `ProcessEnv`,
|
|
61
|
+
* qui EXIGE `NODE_ENV` : un test ne pourrait plus passer un environnement
|
|
62
|
+
* fabriqué, et la bascule deviendrait invérifiable.
|
|
63
|
+
*
|
|
64
|
+
* @param {Record<string, string | undefined>} [env]
|
|
65
|
+
* @returns {boolean}
|
|
66
|
+
*/
|
|
67
|
+
export function lectureSeule(env = process.env) {
|
|
68
|
+
const v = (env.CLICBASE_READ_ONLY ?? "").trim().toLowerCase();
|
|
69
|
+
if (v === "" || v === "0" || v === "false" || v === "non") return false;
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Cet outil doit-il être proposé ?
|
|
75
|
+
*
|
|
76
|
+
* ⚠️ UN OUTIL INCONNU EST TRAITÉ COMME UNE ÉCRITURE. C'est le point le plus
|
|
77
|
+
* important de ce fichier. Le jour où quelqu'un ajoute un outil sans le classer
|
|
78
|
+
* ici, il sera RETIRÉ du mode lecture seule au lieu d'y être admis par défaut.
|
|
79
|
+
* Un oubli coûte alors une fonction manquante, que l'on remarque ; l'autre
|
|
80
|
+
* défaut aurait coûté une écriture ouverte dans un mode qui promet de ne pas
|
|
81
|
+
* écrire, et celui-là ne se remarque pas.
|
|
82
|
+
*/
|
|
83
|
+
export function outilPermis(nom, enLectureSeule) {
|
|
84
|
+
if (!enLectureSeule) return true;
|
|
85
|
+
const genre = OUTILS[nom] ?? "ecriture";
|
|
86
|
+
return PERMIS_EN_LECTURE.has(genre);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Enveloppe `server.tool` pour n'enregistrer que ce qui est permis.
|
|
91
|
+
*
|
|
92
|
+
* Renvoie aussi la liste de ce qui a été retiré, pour que le serveur puisse le
|
|
93
|
+
* DIRE au démarrage : un mode restreint silencieux se découvre en pleine
|
|
94
|
+
* session, quand un outil attendu manque sans explication.
|
|
95
|
+
*/
|
|
96
|
+
export function filtre(server, enLectureSeule) {
|
|
97
|
+
const retires = [];
|
|
98
|
+
return {
|
|
99
|
+
tool(nom, ...reste) {
|
|
100
|
+
if (!outilPermis(nom, enLectureSeule)) {
|
|
101
|
+
retires.push(nom);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
server.tool(nom, ...reste);
|
|
105
|
+
},
|
|
106
|
+
retires,
|
|
107
|
+
};
|
|
108
|
+
}
|