Agregar servidor MCP Metabase
Some checks failed
build-and-deploy / build-and-deploy (push) Failing after 9s
Some checks failed
build-and-deploy / build-and-deploy (push) Failing after 9s
- Implementado mcp-metabase-server con TypeScript - 9 herramientas para interactuar con Metabase API - Soporta listar/buscar cards, ejecutar queries con parámetros - Soporta crear y actualizar cards - Autenticación con API Key - Agregado servicio al docker-compose.yml - Configurado en Traefik sin autenticación Authentik - Actualizado README con documentación completa - Variables y secrets configurados en Gitea
This commit is contained in:
348
mcp-metabase-server/src/index.ts
Normal file
348
mcp-metabase-server/src/index.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import express from 'express';
|
||||
import { z } from 'zod';
|
||||
|
||||
// ==================== Configuración ====================
|
||||
const METABASE_URL = process.env.METABASE_URL || 'http://metabase:3000';
|
||||
const METABASE_API_KEY = process.env.METABASE_API_KEY || '';
|
||||
const PORT = parseInt(process.env.PORT || '3000', 10);
|
||||
|
||||
if (!METABASE_API_KEY) {
|
||||
console.error('ERROR: METABASE_API_KEY no está configurada');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ==================== Cliente Metabase ====================
|
||||
async function metabaseFetch<T = any>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const url = `${METABASE_URL}${endpoint}`;
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-KEY': METABASE_API_KEY,
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Metabase API error (${response.status}): ${errorText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error(`Error en petición a ${endpoint}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Servidor MCP ====================
|
||||
const server = new McpServer({
|
||||
name: 'nucleodocs-metabase',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
// ==================== Herramientas ====================
|
||||
|
||||
// 1. Listar/buscar cards
|
||||
server.registerTool({
|
||||
name: 'metabase_cards',
|
||||
metadata: {
|
||||
title: 'Listar o buscar Cards/Questions',
|
||||
description: 'Lista todas las cards o busca cards por nombre/colección',
|
||||
},
|
||||
inputSchema: z.object({
|
||||
action: z.enum(['list', 'search']).describe('Acción a realizar'),
|
||||
query: z.string().optional().describe('Término de búsqueda (para action=search)'),
|
||||
collection_id: z.number().optional().describe('ID de colección para filtrar'),
|
||||
}),
|
||||
outputSchema: z.object({
|
||||
cards: z.array(z.any()),
|
||||
count: z.number(),
|
||||
}),
|
||||
handler: async (input) => {
|
||||
const { action, query, collection_id } = input;
|
||||
|
||||
let endpoint = '/api/card';
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (collection_id !== undefined) {
|
||||
params.append('collection', collection_id.toString());
|
||||
}
|
||||
|
||||
if (params.toString()) {
|
||||
endpoint += `?${params.toString()}`;
|
||||
}
|
||||
|
||||
const cards = await metabaseFetch<any[]>(endpoint);
|
||||
|
||||
let filteredCards = cards;
|
||||
if (action === 'search' && query) {
|
||||
const searchLower = query.toLowerCase();
|
||||
filteredCards = cards.filter(card =>
|
||||
card.name?.toLowerCase().includes(searchLower) ||
|
||||
card.description?.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
cards: filteredCards,
|
||||
count: filteredCards.length,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// 2. Obtener detalles de una card
|
||||
server.registerTool({
|
||||
name: 'metabase_card_info',
|
||||
metadata: {
|
||||
title: 'Obtener detalles de una Card',
|
||||
description: 'Obtiene información detallada de una card específica por su ID',
|
||||
},
|
||||
inputSchema: z.object({
|
||||
card_id: z.number().describe('ID de la card'),
|
||||
}),
|
||||
outputSchema: z.any(),
|
||||
handler: async (input) => {
|
||||
const { card_id } = input;
|
||||
const card = await metabaseFetch(`/api/card/${card_id}`);
|
||||
return card;
|
||||
},
|
||||
});
|
||||
|
||||
// 3. Ejecutar card con parámetros
|
||||
server.registerTool({
|
||||
name: 'metabase_execute_card',
|
||||
metadata: {
|
||||
title: 'Ejecutar Card con parámetros',
|
||||
description: 'Ejecuta una card/question existente, opcionalmente con parámetros',
|
||||
},
|
||||
inputSchema: z.object({
|
||||
card_id: z.number().describe('ID de la card a ejecutar'),
|
||||
parameters: z.array(z.object({
|
||||
type: z.string().describe('Tipo de parámetro (ej: date/single, category, etc.)'),
|
||||
target: z.any().describe('Target del parámetro'),
|
||||
value: z.any().describe('Valor del parámetro'),
|
||||
})).optional().describe('Parámetros para la query'),
|
||||
}),
|
||||
outputSchema: z.any(),
|
||||
handler: async (input) => {
|
||||
const { card_id, parameters } = input;
|
||||
|
||||
const body: any = {};
|
||||
if (parameters && parameters.length > 0) {
|
||||
body.parameters = parameters;
|
||||
}
|
||||
|
||||
const result = await metabaseFetch(`/api/card/${card_id}/query`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
// 4. Crear nueva card
|
||||
server.registerTool({
|
||||
name: 'metabase_create_card',
|
||||
metadata: {
|
||||
title: 'Crear nueva Card',
|
||||
description: 'Crea una nueva card/question en Metabase',
|
||||
},
|
||||
inputSchema: z.object({
|
||||
name: z.string().describe('Nombre de la card'),
|
||||
description: z.string().optional().describe('Descripción de la card'),
|
||||
dataset_query: z.object({
|
||||
type: z.enum(['native', 'query']).describe('Tipo de query'),
|
||||
database: z.number().describe('ID de la base de datos'),
|
||||
native: z.object({
|
||||
query: z.string().describe('Query SQL'),
|
||||
template_tags: z.record(z.any()).optional().describe('Tags de template para parámetros'),
|
||||
}).optional().describe('Query nativa SQL (si type=native)'),
|
||||
query: z.any().optional().describe('Query MBQL (si type=query)'),
|
||||
}).describe('Configuración de la query'),
|
||||
display: z.string().default('table').describe('Tipo de visualización (table, bar, line, etc.)'),
|
||||
visualization_settings: z.record(z.any()).optional().describe('Configuración de visualización'),
|
||||
collection_id: z.number().optional().describe('ID de la colección donde guardar la card'),
|
||||
}),
|
||||
outputSchema: z.any(),
|
||||
handler: async (input) => {
|
||||
const body: any = {
|
||||
name: input.name,
|
||||
dataset_query: input.dataset_query,
|
||||
display: input.display,
|
||||
visualization_settings: input.visualization_settings || {},
|
||||
};
|
||||
|
||||
if (input.description) {
|
||||
body.description = input.description;
|
||||
}
|
||||
|
||||
if (input.collection_id !== undefined) {
|
||||
body.collection_id = input.collection_id;
|
||||
}
|
||||
|
||||
const result = await metabaseFetch('/api/card', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
// 5. Actualizar card (nombre/descripción)
|
||||
server.registerTool({
|
||||
name: 'metabase_update_card',
|
||||
metadata: {
|
||||
title: 'Actualizar Card',
|
||||
description: 'Actualiza el nombre y/o descripción de una card existente',
|
||||
},
|
||||
inputSchema: z.object({
|
||||
card_id: z.number().describe('ID de la card a actualizar'),
|
||||
name: z.string().optional().describe('Nuevo nombre de la card'),
|
||||
description: z.string().optional().describe('Nueva descripción de la card'),
|
||||
}),
|
||||
outputSchema: z.any(),
|
||||
handler: async (input) => {
|
||||
const { card_id, name, description } = input;
|
||||
|
||||
if (!name && !description) {
|
||||
throw new Error('Debe proporcionar al menos name o description para actualizar');
|
||||
}
|
||||
|
||||
const body: any = {};
|
||||
if (name) body.name = name;
|
||||
if (description !== undefined) body.description = description;
|
||||
|
||||
const result = await metabaseFetch(`/api/card/${card_id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
// 6. Listar colecciones
|
||||
server.registerTool({
|
||||
name: 'metabase_collections',
|
||||
metadata: {
|
||||
title: 'Listar Colecciones',
|
||||
description: 'Lista todas las colecciones disponibles en Metabase',
|
||||
},
|
||||
inputSchema: z.object({}),
|
||||
outputSchema: z.array(z.any()),
|
||||
handler: async () => {
|
||||
const collections = await metabaseFetch<any[]>('/api/collection');
|
||||
return collections;
|
||||
},
|
||||
});
|
||||
|
||||
// 7. Listar bases de datos
|
||||
server.registerTool({
|
||||
name: 'metabase_databases',
|
||||
metadata: {
|
||||
title: 'Listar Bases de Datos',
|
||||
description: 'Lista todas las bases de datos configuradas en Metabase',
|
||||
},
|
||||
inputSchema: z.object({
|
||||
include_tables: z.boolean().optional().default(false).describe('Incluir metadatos de tablas y campos'),
|
||||
}),
|
||||
outputSchema: z.any(),
|
||||
handler: async (input) => {
|
||||
const { include_tables } = input;
|
||||
const databases = await metabaseFetch<any[]>('/api/database');
|
||||
|
||||
if (include_tables) {
|
||||
// Obtener metadata completa de cada base de datos
|
||||
const databasesWithMetadata = await Promise.all(
|
||||
databases.map(async (db) => {
|
||||
try {
|
||||
const metadata = await metabaseFetch(`/api/database/${db.id}/metadata`);
|
||||
return { ...db, metadata };
|
||||
} catch (error) {
|
||||
console.error(`Error obteniendo metadata de DB ${db.id}:`, error);
|
||||
return db;
|
||||
}
|
||||
})
|
||||
);
|
||||
return databasesWithMetadata;
|
||||
}
|
||||
|
||||
return databases;
|
||||
},
|
||||
});
|
||||
|
||||
// 8. Listar dashboards
|
||||
server.registerTool({
|
||||
name: 'metabase_dashboards',
|
||||
metadata: {
|
||||
title: 'Listar Dashboards',
|
||||
description: 'Lista todos los dashboards disponibles',
|
||||
},
|
||||
inputSchema: z.object({}),
|
||||
outputSchema: z.array(z.any()),
|
||||
handler: async () => {
|
||||
const dashboards = await metabaseFetch<any[]>('/api/dashboard');
|
||||
return dashboards;
|
||||
},
|
||||
});
|
||||
|
||||
// 9. Obtener detalles de un dashboard
|
||||
server.registerTool({
|
||||
name: 'metabase_dashboard_info',
|
||||
metadata: {
|
||||
title: 'Obtener detalles de Dashboard',
|
||||
description: 'Obtiene información detallada de un dashboard específico incluyendo sus cards',
|
||||
},
|
||||
inputSchema: z.object({
|
||||
dashboard_id: z.number().describe('ID del dashboard'),
|
||||
}),
|
||||
outputSchema: z.any(),
|
||||
handler: async (input) => {
|
||||
const { dashboard_id } = input;
|
||||
const dashboard = await metabaseFetch(`/api/dashboard/${dashboard_id}`);
|
||||
return dashboard;
|
||||
},
|
||||
});
|
||||
|
||||
// ==================== Servidor Express ====================
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
// Health check
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
server: 'nucleodocs-metabase',
|
||||
metabase_url: METABASE_URL,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
// Endpoint MCP (Traefik hace StripPrefix de /mcp)
|
||||
app.post('/', async (req, res) => {
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
endpoint: '/',
|
||||
sessionIdGenerator: () => Math.random().toString(36).substring(7),
|
||||
});
|
||||
|
||||
await transport.handleRequest(req, res, server);
|
||||
});
|
||||
|
||||
// Iniciar servidor
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`✅ MCP Metabase Server corriendo en puerto ${PORT}`);
|
||||
console.log(`📊 Conectado a Metabase: ${METABASE_URL}`);
|
||||
console.log(`🔑 Usando autenticación con API Key`);
|
||||
});
|
||||
Reference in New Issue
Block a user