From acb73401aafd9e344b8fa3aa897aa9c02da9aa57 Mon Sep 17 00:00:00 2001 From: josedario87 Date: Tue, 28 Oct 2025 11:47:46 -0600 Subject: [PATCH] =?UTF-8?q?Corregir=20implementaci=C3=B3n=20MCP=20Metabase?= =?UTF-8?q?=20con=20API=20correcta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Usar registerTool con 3 argumentos (nombre, metadata, handler) - InputSchema y outputSchema como objetos planos con propiedades Zod - Handlers retornan {content, structuredContent} o {content, isError} - Transport sin endpoint, usar sessionIdGenerator: undefined - Corregir tipo HeadersInit a Record - Soluciona errores de TypeScript en el build --- mcp-metabase-server/src/index.ts | 548 +++++++++++++++++++------------ 1 file changed, 332 insertions(+), 216 deletions(-) diff --git a/mcp-metabase-server/src/index.ts b/mcp-metabase-server/src/index.ts index 83419ad..6f10eee 100644 --- a/mcp-metabase-server/src/index.ts +++ b/mcp-metabase-server/src/index.ts @@ -20,12 +20,15 @@ async function metabaseFetch( ): Promise { const url = `${METABASE_URL}${endpoint}`; - const headers: HeadersInit = { + const headers: Record = { 'Content-Type': 'application/json', 'X-API-KEY': METABASE_API_KEY, - ...options.headers, }; + if (options.headers) { + Object.assign(headers, options.headers); + } + try { const response = await fetch(url, { ...options, @@ -53,268 +56,362 @@ const server = new McpServer({ // ==================== Herramientas ==================== // 1. Listar/buscar cards -server.registerTool({ - name: 'metabase_cards', - metadata: { +server.registerTool( + 'metabase_cards', + { title: 'Listar o buscar Cards/Questions', description: 'Lista todas las cards o busca cards por nombre/colección', + 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'), + }, + outputSchema: { + cards: z.array(z.any()), + count: z.number(), + } }, - 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; + async ({ action, query, collection_id }) => { + try { + let endpoint = '/api/card'; + const params = new URLSearchParams(); - let endpoint = '/api/card'; - const params = new URLSearchParams(); + if (collection_id !== undefined) { + params.append('collection', collection_id.toString()); + } - if (collection_id !== undefined) { - params.append('collection', collection_id.toString()); + if (params.toString()) { + endpoint += `?${params.toString()}`; + } + + const cards = await metabaseFetch(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) + ); + } + + const result = { + cards: filteredCards, + count: filteredCards.length, + }; + + 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 + }; } - - if (params.toString()) { - endpoint += `?${params.toString()}`; - } - - const cards = await metabaseFetch(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: { +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: z.any() }, - 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; - }, -}); + 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({ - name: 'metabase_execute_card', - metadata: { +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: z.any() }, - 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; + async ({ card_id, parameters }) => { + try { + const body: any = {}; + if (parameters && parameters.length > 0) { + body.parameters = parameters; + } - 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 + }; } - - 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: { +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: z.any() }, - 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 || {}, - }; + 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 (input.description) { - body.description = input.description; + 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 + }; } + } +); - 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: { +// 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: z.any() }, - 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; + async ({ card_id, name, description }) => { + try { + if (!name && !description) { + throw new Error('Debe proporcionar al menos name o description'); + } - 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 { + 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 + }; } - - 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: { +server.registerTool( + 'metabase_collections', + { title: 'Listar Colecciones', description: 'Lista todas las colecciones disponibles en Metabase', + inputSchema: {}, + outputSchema: z.array(z.any()) }, - inputSchema: z.object({}), - outputSchema: z.array(z.any()), - handler: async () => { - const collections = await metabaseFetch('/api/collection'); - return collections; - }, -}); + async () => { + try { + const collections = await metabaseFetch('/api/collection'); + return { + content: [{ type: 'text', text: JSON.stringify(collections, null, 2) }], + structuredContent: collections + }; + } 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({ - name: 'metabase_databases', - metadata: { +server.registerTool( + 'metabase_databases', + { title: 'Listar Bases de Datos', description: 'Lista todas las bases de datos configuradas en Metabase', + inputSchema: { + include_tables: z.boolean().optional().default(false).describe('Incluir metadata de tablas'), + }, + outputSchema: z.any() }, - 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('/api/database'); + async ({ include_tables }) => { + try { + const databases = await metabaseFetch('/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; + if (include_tables) { + 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 { + content: [{ type: 'text', text: JSON.stringify(databasesWithMetadata, null, 2) }], + structuredContent: databasesWithMetadata + }; + } + + return { + content: [{ type: 'text', text: JSON.stringify(databases, null, 2) }], + structuredContent: databases + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Error desconocido'; + return { + content: [{ type: 'text', text: `Error: ${errorMsg}` }], + isError: true + }; } - - return databases; - }, -}); + } +); // 8. Listar dashboards -server.registerTool({ - name: 'metabase_dashboards', - metadata: { +server.registerTool( + 'metabase_dashboards', + { title: 'Listar Dashboards', description: 'Lista todos los dashboards disponibles', + inputSchema: {}, + outputSchema: z.array(z.any()) }, - inputSchema: z.object({}), - outputSchema: z.array(z.any()), - handler: async () => { - const dashboards = await metabaseFetch('/api/dashboard'); - return dashboards; - }, -}); + 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({ - name: 'metabase_dashboard_info', - metadata: { +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: z.any() }, - 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; - }, -}); + 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(); @@ -332,12 +429,31 @@ app.get('/health', (req, res) => { // Endpoint MCP (Traefik hace StripPrefix de /mcp) app.post('/', async (req, res) => { - const transport = new StreamableHTTPServerTransport({ - endpoint: '/', - sessionIdGenerator: () => Math.random().toString(36).substring(7), - }); + try { + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true + }); - await transport.handleRequest(req, res, server); + 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