@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 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());
@@ -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
+ }