mcp-supabase-selfhosted 1.0.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.
@@ -0,0 +1,566 @@
1
+ import { query } from '../db/postgres.js';
2
+ import { getSupabaseClient } from '../supabase/client.js';
3
+
4
+ export const toolsDefinitions = [
5
+ {
6
+ name: 'list_tables',
7
+ description: 'Lista todas las tablas en un esquema específico de la base de datos PostgreSQL.',
8
+ inputSchema: {
9
+ type: 'object',
10
+ properties: {
11
+ schema: {
12
+ type: 'string',
13
+ description: "El nombre del esquema (ej. 'public', 'auth'). Por defecto es 'public'.",
14
+ },
15
+ },
16
+ },
17
+ },
18
+ {
19
+ name: 'execute_sql',
20
+ description:
21
+ 'Ejecuta una consulta SQL cruda en la base de datos PostgreSQL de Supabase. Útil para leer datos, modificar esquemas o administrar la base de datos. ATENCIÓN: Esta herramienta tiene acceso directo, sin pasar por RLS.',
22
+ inputSchema: {
23
+ type: 'object',
24
+ properties: {
25
+ sql: {
26
+ type: 'string',
27
+ description: 'La consulta SQL a ejecutar.',
28
+ },
29
+ },
30
+ required: ['sql'],
31
+ },
32
+ },
33
+ {
34
+ name: 'list_users',
35
+ description:
36
+ 'Lista los usuarios registrados en el servicio de Autenticación de Supabase (auth.users). Devuelve información básica de los usuarios.',
37
+ inputSchema: {
38
+ type: 'object',
39
+ properties: {
40
+ page: {
41
+ type: 'number',
42
+ description: 'El número de página para paginación (por defecto 1).',
43
+ },
44
+ perPage: {
45
+ type: 'number',
46
+ description: 'La cantidad de usuarios por página (por defecto 50).',
47
+ },
48
+ },
49
+ },
50
+ },
51
+ {
52
+ name: 'create_user',
53
+ description:
54
+ 'Crea un nuevo usuario en Supabase Auth. Útil para inicializar cuentas administrativas o de prueba.',
55
+ inputSchema: {
56
+ type: 'object',
57
+ properties: {
58
+ email: { type: 'string', description: 'Correo electrónico del usuario.' },
59
+ password: { type: 'string', description: 'Contraseña del usuario (mínimo 6 caracteres).' },
60
+ email_confirm: {
61
+ type: 'boolean',
62
+ description: 'Si es true, autoconfirma el email (por defecto true).',
63
+ },
64
+ },
65
+ required: ['email', 'password'],
66
+ },
67
+ },
68
+ {
69
+ name: 'delete_user',
70
+ description: 'Elimina un usuario de Supabase Auth por su ID.',
71
+ inputSchema: {
72
+ type: 'object',
73
+ properties: {
74
+ user_id: { type: 'string', description: 'El UUID del usuario a eliminar.' },
75
+ confirm: {
76
+ type: 'boolean',
77
+ description: 'Debe ser true para confirmar la eliminación destructiva.',
78
+ },
79
+ },
80
+ required: ['user_id', 'confirm'],
81
+ },
82
+ },
83
+ {
84
+ name: 'list_buckets',
85
+ description:
86
+ 'Lista todos los buckets de almacenamiento (Storage) configurados en el proyecto de Supabase.',
87
+ inputSchema: {
88
+ type: 'object',
89
+ properties: {},
90
+ },
91
+ },
92
+ {
93
+ name: 'create_bucket',
94
+ description: 'Crea un nuevo bucket de almacenamiento (Storage) en Supabase.',
95
+ inputSchema: {
96
+ type: 'object',
97
+ properties: {
98
+ bucket: { type: 'string', description: 'Nombre del nuevo bucket.' },
99
+ public: {
100
+ type: 'boolean',
101
+ description: 'Si el bucket debe ser público (por defecto false).',
102
+ },
103
+ },
104
+ required: ['bucket'],
105
+ },
106
+ },
107
+ {
108
+ name: 'delete_bucket',
109
+ description:
110
+ 'Elimina un bucket de almacenamiento (Storage) en Supabase. El bucket debe estar vacío o fallará.',
111
+ inputSchema: {
112
+ type: 'object',
113
+ properties: {
114
+ bucket: { type: 'string', description: 'Nombre del bucket a eliminar.' },
115
+ confirm: {
116
+ type: 'boolean',
117
+ description: 'Debe ser true para confirmar la eliminación destructiva.',
118
+ },
119
+ },
120
+ required: ['bucket', 'confirm'],
121
+ },
122
+ },
123
+ {
124
+ name: 'get_schema',
125
+ description:
126
+ 'Obtiene el esquema de la base de datos o de una tabla específica. Útil para entender la estructura antes de ejecutar SQL.',
127
+ inputSchema: {
128
+ type: 'object',
129
+ properties: {
130
+ table_name: {
131
+ type: 'string',
132
+ description:
133
+ 'Nombre de la tabla para obtener sus columnas. Si se omite, devuelve una lista de todas las tablas con sus columnas.',
134
+ },
135
+ schema: {
136
+ type: 'string',
137
+ description: "El nombre del esquema (ej. 'public'). Por defecto es 'public'.",
138
+ },
139
+ },
140
+ },
141
+ },
142
+ {
143
+ name: 'get_advisors',
144
+ description:
145
+ 'Obtiene alertas y recomendaciones de rendimiento y seguridad directamente de la base de datos (similar a las alertas del panel de Supabase). Analiza índices sin uso, políticas RLS faltantes y ratio de caché.',
146
+ inputSchema: {
147
+ type: 'object',
148
+ properties: {},
149
+ },
150
+ },
151
+ {
152
+ name: 'list_files',
153
+ description:
154
+ 'Lista los archivos y carpetas dentro de un bucket de almacenamiento (Storage) específico.',
155
+ inputSchema: {
156
+ type: 'object',
157
+ properties: {
158
+ bucket: {
159
+ type: 'string',
160
+ description: 'El nombre del bucket a consultar.',
161
+ },
162
+ path: {
163
+ type: 'string',
164
+ description: 'La ruta de la carpeta dentro del bucket (opcional).',
165
+ },
166
+ },
167
+ required: ['bucket'],
168
+ },
169
+ },
170
+ {
171
+ name: 'list_rls_policies',
172
+ description:
173
+ 'Lista todas las políticas de seguridad a nivel de fila (Row Level Security - RLS) activas en la base de datos. Útil para auditar reglas de acceso.',
174
+ inputSchema: {
175
+ type: 'object',
176
+ properties: {
177
+ schema: {
178
+ type: 'string',
179
+ description: "El esquema a auditar. Por defecto 'public'.",
180
+ },
181
+ },
182
+ },
183
+ },
184
+ {
185
+ name: 'get_active_connections',
186
+ description:
187
+ 'Muestra las conexiones activas actuales a la base de datos y qué consultas están ejecutando. Excelente para depurar problemas de rendimiento, bloqueos o saturación del pool de conexiones.',
188
+ inputSchema: {
189
+ type: 'object',
190
+ properties: {},
191
+ },
192
+ },
193
+ ];
194
+
195
+ export async function handleGetSchema(params: any) {
196
+ const schema = params?.schema || 'public';
197
+ const tableName = params?.table_name;
198
+
199
+ let sql = `
200
+ SELECT table_name, column_name, data_type, is_nullable, column_default
201
+ FROM information_schema.columns
202
+ WHERE table_schema = $1
203
+ `;
204
+ const queryParams = [schema];
205
+
206
+ if (tableName) {
207
+ sql += ` AND table_name = $2`;
208
+ queryParams.push(tableName);
209
+ }
210
+
211
+ sql += ` ORDER BY table_name, ordinal_position;`;
212
+
213
+ try {
214
+ const rows = await query(sql, queryParams);
215
+ return {
216
+ content: [{ type: 'text', text: JSON.stringify(rows, null, 2) }],
217
+ };
218
+ } catch (error: any) {
219
+ return {
220
+ isError: true,
221
+ content: [{ type: 'text', text: `Error obteniendo esquema: ${error.message}` }],
222
+ };
223
+ }
224
+ }
225
+
226
+ export async function handleListTables(params: any) {
227
+ const schema = params?.schema || 'public';
228
+ const sql = `
229
+ SELECT table_name
230
+ FROM information_schema.tables
231
+ WHERE table_schema = $1 AND table_type = 'BASE TABLE'
232
+ ORDER BY table_name;
233
+ `;
234
+
235
+ try {
236
+ const rows = await query(sql, [schema]);
237
+ return {
238
+ content: [{ type: 'text', text: JSON.stringify(rows, null, 2) }],
239
+ };
240
+ } catch (error: any) {
241
+ return {
242
+ isError: true,
243
+ content: [{ type: 'text', text: `Error listando tablas: ${error.message}` }],
244
+ };
245
+ }
246
+ }
247
+
248
+ export async function handleExecuteSql(params: any) {
249
+ const { sql } = params;
250
+ if (!sql) {
251
+ return {
252
+ isError: true,
253
+ content: [{ type: 'text', text: "El parámetro 'sql' es obligatorio." }],
254
+ };
255
+ }
256
+
257
+ try {
258
+ const rows = await query(sql);
259
+ return {
260
+ content: [{ type: 'text', text: JSON.stringify(rows, null, 2) }],
261
+ };
262
+ } catch (error: any) {
263
+ return {
264
+ isError: true,
265
+ content: [{ type: 'text', text: `Error ejecutando SQL: ${error.message}` }],
266
+ };
267
+ }
268
+ }
269
+
270
+ export async function handleListUsers(params: any) {
271
+ const supabase = getSupabaseClient();
272
+ const page = params?.page || 1;
273
+ const perPage = params?.perPage || 50;
274
+
275
+ try {
276
+ const { data, error } = await supabase.auth.admin.listUsers({
277
+ page,
278
+ perPage,
279
+ });
280
+
281
+ if (error) throw error;
282
+
283
+ return {
284
+ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
285
+ };
286
+ } catch (error: any) {
287
+ return {
288
+ isError: true,
289
+ content: [{ type: 'text', text: `Error listando usuarios: ${error.message}` }],
290
+ };
291
+ }
292
+ }
293
+
294
+ export async function handleCreateUser(params: any) {
295
+ const supabase = getSupabaseClient();
296
+ const { email, password, email_confirm = true } = params;
297
+
298
+ if (!email || !password) {
299
+ return {
300
+ isError: true,
301
+ content: [{ type: 'text', text: "Los parámetros 'email' y 'password' son obligatorios." }],
302
+ };
303
+ }
304
+
305
+ try {
306
+ const { data, error } = await supabase.auth.admin.createUser({
307
+ email,
308
+ password,
309
+ email_confirm,
310
+ });
311
+
312
+ if (error) throw error;
313
+
314
+ return {
315
+ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
316
+ };
317
+ } catch (error: any) {
318
+ return {
319
+ isError: true,
320
+ content: [{ type: 'text', text: `Error creando usuario: ${error.message}` }],
321
+ };
322
+ }
323
+ }
324
+
325
+ export async function handleDeleteUser(params: any) {
326
+ const supabase = getSupabaseClient();
327
+ const { user_id } = params;
328
+
329
+ if (!user_id) {
330
+ return {
331
+ isError: true,
332
+ content: [{ type: 'text', text: "El parámetro 'user_id' es obligatorio." }],
333
+ };
334
+ }
335
+
336
+ try {
337
+ const { data, error } = await supabase.auth.admin.deleteUser(user_id);
338
+
339
+ if (error) throw error;
340
+
341
+ return {
342
+ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
343
+ };
344
+ } catch (error: any) {
345
+ return {
346
+ isError: true,
347
+ content: [{ type: 'text', text: `Error eliminando usuario: ${error.message}` }],
348
+ };
349
+ }
350
+ }
351
+
352
+ export async function handleListBuckets() {
353
+ const supabase = getSupabaseClient();
354
+
355
+ try {
356
+ const { data, error } = await supabase.storage.listBuckets();
357
+
358
+ if (error) throw error;
359
+
360
+ return {
361
+ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
362
+ };
363
+ } catch (error: any) {
364
+ return {
365
+ isError: true,
366
+ content: [{ type: 'text', text: `Error listando buckets: ${error.message}` }],
367
+ };
368
+ }
369
+ }
370
+
371
+ export async function handleCreateBucket(params: any) {
372
+ const supabase = getSupabaseClient();
373
+ const { bucket, public: isPublic = false } = params;
374
+
375
+ if (!bucket) {
376
+ return {
377
+ isError: true,
378
+ content: [{ type: 'text', text: "El parámetro 'bucket' es obligatorio." }],
379
+ };
380
+ }
381
+
382
+ try {
383
+ const { data, error } = await supabase.storage.createBucket(bucket, {
384
+ public: isPublic,
385
+ });
386
+
387
+ if (error) throw error;
388
+
389
+ return {
390
+ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
391
+ };
392
+ } catch (error: any) {
393
+ return {
394
+ isError: true,
395
+ content: [{ type: 'text', text: `Error creando bucket: ${error.message}` }],
396
+ };
397
+ }
398
+ }
399
+
400
+ export async function handleDeleteBucket(params: any) {
401
+ const supabase = getSupabaseClient();
402
+ const { bucket, confirm } = params || {};
403
+
404
+ if (!bucket || confirm !== true) {
405
+ return {
406
+ isError: true,
407
+ content: [
408
+ { type: 'text', text: "El parámetro 'bucket' es obligatorio y 'confirm' debe ser true." },
409
+ ],
410
+ };
411
+ }
412
+
413
+ try {
414
+ const { data, error } = await supabase.storage.deleteBucket(bucket);
415
+
416
+ if (error) throw error;
417
+
418
+ return {
419
+ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
420
+ };
421
+ } catch (error: any) {
422
+ return {
423
+ isError: true,
424
+ content: [{ type: 'text', text: `Error eliminando bucket: ${error.message}` }],
425
+ };
426
+ }
427
+ }
428
+
429
+ export async function handleGetAdvisors() {
430
+ try {
431
+ // 1. Verificar tablas sin RLS (Seguridad)
432
+ const rlsSql = `
433
+ SELECT relname as table_name
434
+ FROM pg_class
435
+ JOIN pg_namespace ON pg_namespace.oid = pg_class.relnamespace
436
+ WHERE nspname = 'public' AND relkind = 'r' AND relrowsecurity = false;
437
+ `;
438
+ const rlsRows = await query(rlsSql);
439
+
440
+ // 2. Verificar índices no utilizados (Rendimiento)
441
+ const indexesSql = `
442
+ SELECT schemaname, relname as table_name, indexrelname as index_name, idx_scan
443
+ FROM pg_stat_user_indexes
444
+ WHERE idx_scan = 0 AND schemaname = 'public';
445
+ `;
446
+ const unusedIndexesRows = await query(indexesSql);
447
+
448
+ // 3. Ratio de Caché (Salud General)
449
+ const cacheSql = `
450
+ SELECT
451
+ sum(blks_hit)*100/sum(blks_hit+blks_read) as cache_hit_ratio
452
+ FROM pg_stat_database;
453
+ `;
454
+ const cacheRows = await query(cacheSql);
455
+
456
+ const report = {
457
+ security: {
458
+ issue: 'Tablas sin Row Level Security (RLS) habilitado',
459
+ description: 'Estas tablas están expuestas a la API anónima si no configuras RLS.',
460
+ tables_affected: rlsRows.map((r: any) => r.table_name),
461
+ },
462
+ performance: {
463
+ unused_indexes: {
464
+ issue: 'Índices sin uso',
465
+ description:
466
+ 'Índices que ocupan espacio y ralentizan escrituras pero no se están usando en lecturas.',
467
+ indexes: unusedIndexesRows,
468
+ },
469
+ cache_health: {
470
+ issue: 'Ratio de acierto en caché',
471
+ description: 'Debe estar lo más cerca posible al 99%.',
472
+ ratio_percentage: cacheRows[0]?.cache_hit_ratio || 'N/A',
473
+ },
474
+ },
475
+ };
476
+
477
+ return {
478
+ content: [{ type: 'text', text: JSON.stringify(report, null, 2) }],
479
+ };
480
+ } catch (error: any) {
481
+ return {
482
+ isError: true,
483
+ content: [{ type: 'text', text: `Error obteniendo alertas: ${error.message}` }],
484
+ };
485
+ }
486
+ }
487
+
488
+ export async function handleListFiles(params: any) {
489
+ const supabase = getSupabaseClient();
490
+ const { bucket, path = '' } = params;
491
+
492
+ if (!bucket) {
493
+ return {
494
+ isError: true,
495
+ content: [{ type: 'text', text: "El parámetro 'bucket' es obligatorio." }],
496
+ };
497
+ }
498
+
499
+ try {
500
+ const { data, error } = await supabase.storage.from(bucket).list(path);
501
+
502
+ if (error) throw error;
503
+
504
+ return {
505
+ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
506
+ };
507
+ } catch (error: any) {
508
+ return {
509
+ isError: true,
510
+ content: [{ type: 'text', text: `Error listando archivos en el bucket: ${error.message}` }],
511
+ };
512
+ }
513
+ }
514
+
515
+ export async function handleListRlsPolicies(params: any) {
516
+ const schema = params?.schema || 'public';
517
+
518
+ const sql = `
519
+ SELECT schemaname, tablename, policyname, permissive, roles, cmd, qual, with_check
520
+ FROM pg_policies
521
+ WHERE schemaname = $1
522
+ ORDER BY tablename, policyname;
523
+ `;
524
+
525
+ try {
526
+ const rows = await query(sql, [schema]);
527
+ return {
528
+ content: [{ type: 'text', text: JSON.stringify(rows, null, 2) }],
529
+ };
530
+ } catch (error: any) {
531
+ return {
532
+ isError: true,
533
+ content: [{ type: 'text', text: `Error listando políticas RLS: ${error.message}` }],
534
+ };
535
+ }
536
+ }
537
+
538
+ export async function handleGetActiveConnections() {
539
+ const sql = `
540
+ SELECT
541
+ pid,
542
+ usename as user,
543
+ application_name,
544
+ client_addr,
545
+ backend_start,
546
+ state,
547
+ wait_event_type,
548
+ wait_event,
549
+ query
550
+ FROM pg_stat_activity
551
+ WHERE state IS NOT NULL
552
+ ORDER BY backend_start DESC;
553
+ `;
554
+
555
+ try {
556
+ const rows = await query(sql);
557
+ return {
558
+ content: [{ type: 'text', text: JSON.stringify(rows, null, 2) }],
559
+ };
560
+ } catch (error: any) {
561
+ return {
562
+ isError: true,
563
+ content: [{ type: 'text', text: `Error obteniendo conexiones activas: ${error.message}` }],
564
+ };
565
+ }
566
+ }
@@ -0,0 +1,34 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { toolsDefinitions } from '../src/tools/index.js';
4
+
5
+ test('El servidor MCP tiene todas las herramientas requeridas exportadas', (t) => {
6
+ const toolNames = toolsDefinitions.map(tool => tool.name);
7
+
8
+ const expectedTools = [
9
+ 'list_tables',
10
+ 'execute_sql',
11
+ 'get_schema',
12
+ 'get_advisors',
13
+ 'list_users',
14
+ 'create_user',
15
+ 'delete_user',
16
+ 'list_buckets',
17
+ 'create_bucket',
18
+ 'delete_bucket',
19
+ 'list_files',
20
+ 'list_rls_policies',
21
+ 'get_active_connections'
22
+ ];
23
+
24
+ for (const expected of expectedTools) {
25
+ assert.ok(toolNames.includes(expected), `Falta la herramienta: ${expected}`);
26
+ }
27
+ });
28
+
29
+ test('Las herramientas tienen un inputSchema válido', (t) => {
30
+ for (const tool of toolsDefinitions) {
31
+ assert.ok(tool.inputSchema, `La herramienta ${tool.name} no tiene inputSchema`);
32
+ assert.strictEqual(tool.inputSchema.type, 'object', `El inputSchema de ${tool.name} debe ser un objeto`);
33
+ }
34
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "rootDir": "./src",
7
+ "outDir": "./dist",
8
+ "esModuleInterop": true,
9
+ "forceConsistentCasingInFileNames": true,
10
+ "strict": true,
11
+ "skipLibCheck": true,
12
+ "resolveJsonModule": true,
13
+ "declaration": true
14
+ },
15
+ "include": ["src/**/*"],
16
+ "exclude": ["node_modules", "dist"]
17
+ }