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( endpoint: string, options: RequestInit = {} ): Promise { const url = `${METABASE_URL}${endpoint}`; const headers: Record = { 'Content-Type': 'application/json', 'X-API-KEY': METABASE_API_KEY, }; if (options.headers) { Object.assign(headers, 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() as T; } catch (error) { console.error(`Error en petición a ${endpoint}:`, error); throw error; } } // ==================== Funciones Helper ==================== /** * Filtra campos de una card según el modo verbose * @param card - Card completa de la API * @param verbose - Si true, devuelve todos los campos. Si false, solo campos esenciales * @returns Card filtrada */ function filterCardFields(card: any, verbose: boolean): any { if (verbose) { return card; // Modo verbose: devolver todos los campos } // Modo default (ligero): solo campos esenciales para listados return { id: card.id, name: card.name, description: card.description, collection_id: card.collection_id, collection: card.collection ? { id: card.collection.id, name: card.collection.name, } : null, database_id: card.database_id, query_type: card.query_type, display: card.display, type: card.type, created_at: card.created_at, updated_at: card.updated_at, last_used_at: card.last_used_at, view_count: card.view_count, archived: card.archived, }; } /** * Filtra campos de una database según el modo verbose * @param db - Database completa de la API * @param verbose - Si true, devuelve todos los campos. Si false, omite features * @returns Database filtrada */ function filterDatabaseFields(db: any, verbose: boolean): any { if (verbose) { return db; // Modo verbose: devolver todos los campos } // Modo default (ligero): sin features array (muy grande) return { id: db.id, name: db.name, engine: db.engine, is_sample: db.is_sample, is_on_demand: db.is_on_demand, created_at: db.created_at, updated_at: db.updated_at, // Omitir: features, details, y otros campos técnicos }; } /** * Filtra campos de una collection según el modo verbose * @param collection - Collection completa de la API * @param verbose - Si true, devuelve todos los campos. Si false, solo campos básicos * @returns Collection filtrada */ function filterCollectionFields(collection: any, verbose: boolean): any { if (verbose) { return collection; // Modo verbose: devolver todos los campos } // Modo default (ligero): solo info básica return { id: collection.id, name: collection.name, location: collection.location, description: collection.description, archived: collection.archived, personal_owner_id: collection.personal_owner_id, // Omitir: effective_ancestors, authority_level, permisos, etc. }; } // ==================== Servidor MCP ==================== const server = new McpServer({ name: 'nucleodocs-metabase', version: '1.0.0', }); // ==================== Herramientas ==================== // 1. Listar/buscar cards server.registerTool( 'metabase_cards', { title: 'Listar o buscar Cards/Questions', description: 'Lista todas las cards o busca cards por nombre/colección. Por defecto devuelve respuesta ligera, usa verbose:true para incluir todos los campos (creator, dataset_query, parameters, etc.)', inputSchema: { 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'), verbose: z.boolean().optional().default(false).describe('Si true, incluye todos los campos (creator, dataset_query, parameters). Default: false (solo campos esenciales)'), page: z.number().optional().default(1).describe('Número de página (1-indexed). Default: 1'), pageSize: z.number().optional().default(20).describe('Items por página. Default: 20, Max: 100'), }, outputSchema: { cards: z.array(z.any()), pagination: z.object({ page: z.number(), pageSize: z.number(), totalItems: z.number(), totalPages: z.number(), hasNextPage: z.boolean(), hasPreviousPage: z.boolean(), }), } }, async ({ action, query, collection_id, verbose = false, page = 1, pageSize = 20 }) => { try { // Validar límites const validPageSize = Math.min(Math.max(pageSize, 1), 100); const validPage = Math.max(page, 1); 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(endpoint); // Aplicar filtro de búsqueda 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) ); } // Filtrar campos según verbose const processedCards = filteredCards.map(card => filterCardFields(card, verbose)); // Aplicar paginación const totalCards = processedCards.length; const totalPages = Math.ceil(totalCards / validPageSize); const startIndex = (validPage - 1) * validPageSize; const endIndex = startIndex + validPageSize; const paginatedCards = processedCards.slice(startIndex, endIndex); const result = { cards: paginatedCards, pagination: { page: validPage, pageSize: validPageSize, totalItems: totalCards, totalPages: totalPages, hasNextPage: validPage < totalPages, hasPreviousPage: validPage > 1, } }; return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], structuredContent: result }; } catch (error) { const errorMsg = error instanceof Error ? error.message : 'Error desconocido'; return { content: [{ type: 'text', text: `Error: ${errorMsg}` }], isError: true }; } } ); // 2. Obtener detalles de una card server.registerTool( 'metabase_card_info', { title: 'Obtener detalles de una Card', description: 'Obtiene información detallada de una card específica por su ID', inputSchema: { card_id: z.number().describe('ID de la card'), }, outputSchema: { result: z.any() } }, async ({ card_id }) => { try { const card = await metabaseFetch(`/api/card/${card_id}`); return { content: [{ type: 'text', text: JSON.stringify(card, null, 2) }], structuredContent: card }; } catch (error) { const errorMsg = error instanceof Error ? error.message : 'Error desconocido'; return { content: [{ type: 'text', text: `Error: ${errorMsg}` }], isError: true }; } } ); // 3. Ejecutar card con parámetros server.registerTool( 'metabase_execute_card', { title: 'Ejecutar Card con parámetros', description: 'Ejecuta una card/question existente, opcionalmente con parámetros', inputSchema: { card_id: z.number().describe('ID de la card a ejecutar'), parameters: z.array(z.object({ type: z.string().describe('Tipo de parámetro'), target: z.any().describe('Target del parámetro'), value: z.any().describe('Valor del parámetro'), })).optional().describe('Parámetros para la query'), }, outputSchema: { result: z.any() } }, async ({ card_id, parameters }) => { try { 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 { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], structuredContent: result }; } catch (error) { const errorMsg = error instanceof Error ? error.message : 'Error desconocido'; return { content: [{ type: 'text', text: `Error: ${errorMsg}` }], isError: true }; } } ); // 4. Crear nueva card server.registerTool( 'metabase_create_card', { title: 'Crear nueva Card', description: 'Crea una nueva card/question en Metabase', inputSchema: { 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'), }).optional(), query: z.any().optional(), }).describe('Configuración de la query'), display: z.string().default('table').describe('Tipo de visualización'), visualization_settings: z.record(z.any()).optional().describe('Configuración de visualización'), collection_id: z.number().optional().describe('ID de la colección'), }, outputSchema: { result: z.any() } }, async ({ name, description, dataset_query, display, visualization_settings, collection_id }) => { try { const body: any = { name, dataset_query, display: display || 'table', visualization_settings: visualization_settings || {}, }; if (description) { body.description = description; } if (collection_id !== undefined) { body.collection_id = collection_id; } const result = await metabaseFetch('/api/card', { method: 'POST', body: JSON.stringify(body), }); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], structuredContent: result }; } catch (error) { const errorMsg = error instanceof Error ? error.message : 'Error desconocido'; return { content: [{ type: 'text', text: `Error: ${errorMsg}` }], isError: true }; } } ); // 5. Actualizar card server.registerTool( 'metabase_update_card', { title: 'Actualizar Card', description: 'Actualiza el nombre y/o descripción de una card existente', inputSchema: { card_id: z.number().describe('ID de la card a actualizar'), name: z.string().optional().describe('Nuevo nombre'), description: z.string().optional().describe('Nueva descripción'), }, outputSchema: { result: z.any() } }, async ({ card_id, name, description }) => { try { if (!name && !description) { throw new Error('Debe proporcionar al menos name o description'); } 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 { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], structuredContent: result }; } catch (error) { const errorMsg = error instanceof Error ? error.message : 'Error desconocido'; return { content: [{ type: 'text', text: `Error: ${errorMsg}` }], isError: true }; } } ); // 6. Listar colecciones server.registerTool( 'metabase_collections', { title: 'Listar Colecciones', description: 'Lista todas las colecciones disponibles en Metabase. Por defecto devuelve info básica, usa verbose:true para incluir permisos y metadata completa', inputSchema: { verbose: z.boolean().optional().default(false).describe('Si true, incluye permisos y metadata completa. Default: false (solo info básica)'), }, outputSchema: { collections: z.array(z.any()) } }, async ({ verbose = false }) => { try { const collections = await metabaseFetch('/api/collection'); // Filtrar campos según verbose const filteredCollections = collections.map(col => filterCollectionFields(col, verbose)); return { content: [{ type: 'text', text: JSON.stringify(filteredCollections, null, 2) }], structuredContent: { collections: filteredCollections } }; } catch (error) { const errorMsg = error instanceof Error ? error.message : 'Error desconocido'; return { content: [{ type: 'text', text: `Error: ${errorMsg}` }], isError: true }; } } ); // 7. Listar bases de datos server.registerTool( 'metabase_databases', { title: 'Listar Bases de Datos', description: 'Lista todas las bases de datos configuradas en Metabase. Por defecto devuelve info básica sin features array, usa verbose:true para incluir todos los detalles técnicos', inputSchema: { verbose: z.boolean().optional().default(false).describe('Si true, incluye features y detalles técnicos. Default: false (solo info básica)'), include_tables: z.boolean().optional().default(false).describe('Incluir metadata de tablas'), }, outputSchema: { databases: z.array(z.any()) } }, async ({ verbose = false, include_tables = false }) => { try { // FIX: La API devuelve { data: [...] } en lugar de array directo const response = await metabaseFetch('/api/database'); const databases = response.data || response; // Fallback si es array directo if (include_tables) { const databasesWithMetadata = await Promise.all( databases.map(async (db: any) => { try { const metadata = await metabaseFetch(`/api/database/${db.id}/metadata`); const filtered = filterDatabaseFields(db, verbose); return { ...filtered, metadata }; } catch (error) { console.error(`Error obteniendo metadata de DB ${db.id}:`, error); return filterDatabaseFields(db, verbose); } }) ); return { content: [{ type: 'text', text: JSON.stringify(databasesWithMetadata, null, 2) }], structuredContent: { databases: databasesWithMetadata } }; } // Filtrar campos según verbose const filteredDatabases = databases.map((db: any) => filterDatabaseFields(db, verbose)); return { content: [{ type: 'text', text: JSON.stringify(filteredDatabases, null, 2) }], structuredContent: { databases: filteredDatabases } }; } catch (error) { const errorMsg = error instanceof Error ? error.message : 'Error desconocido'; return { content: [{ type: 'text', text: `Error: ${errorMsg}` }], isError: true }; } } ); // 8. Listar dashboards server.registerTool( 'metabase_dashboards', { title: 'Listar Dashboards', description: 'Lista todos los dashboards disponibles', inputSchema: {}, outputSchema: { dashboards: z.array(z.any()) } }, async () => { try { const dashboards = await metabaseFetch('/api/dashboard'); return { content: [{ type: 'text', text: JSON.stringify(dashboards, null, 2) }], structuredContent: { dashboards } }; } catch (error) { const errorMsg = error instanceof Error ? error.message : 'Error desconocido'; return { content: [{ type: 'text', text: `Error: ${errorMsg}` }], isError: true }; } } ); // 9. Obtener detalles de un dashboard server.registerTool( 'metabase_dashboard_info', { title: 'Obtener detalles de Dashboard', description: 'Obtiene información detallada de un dashboard específico incluyendo sus cards', inputSchema: { dashboard_id: z.number().describe('ID del dashboard'), }, outputSchema: { result: z.any() } }, async ({ dashboard_id }) => { try { const dashboard = await metabaseFetch(`/api/dashboard/${dashboard_id}`); return { content: [{ type: 'text', text: JSON.stringify(dashboard, null, 2) }], structuredContent: dashboard }; } catch (error) { const errorMsg = error instanceof Error ? error.message : 'Error desconocido'; return { content: [{ type: 'text', text: `Error: ${errorMsg}` }], isError: true }; } } ); // ==================== 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) => { try { const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true }); res.on('close', () => { transport.close(); }); await server.connect(transport); await transport.handleRequest(req, res, req.body); } catch (error) { console.error('Error handling MCP request:', error); if (!res.headersSent) { res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal server error' }, id: null }); } } }); // 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`); });