Files
nucleoDocs/mcp-metabase-server/src/index.ts
josedario87 4cbaee3fbd
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 21s
Implementar parámetro verbose y paginación en MCP Metabase
Mejoras implementadas:
- Parámetro verbose en metabase_cards, metabase_databases, metabase_collections
- Paginación en metabase_cards (page, pageSize)
- Funciones helper para filtrado de campos (filterCardFields, filterDatabaseFields, filterCollectionFields)
- Corrección de bug en metabase_databases con include_tables (API devuelve {data: []} en lugar de array directo)

Resultados:
- Reducción 94.7% en tamaño de respuestas de cards (60KB → 3KB por 5 cards)
- Reducción 90.2% en tamaño de respuestas de databases (2KB → 198 bytes)
- 100% de pruebas exitosas (9/9)

Documentación:
- Agregado ANALISIS-RESULTADOS.md con análisis detallado
- Agregado SPEC-VERBOSE.md con especificación técnica
- Agregado README-TESTING.md con guía de pruebas
- Actualizado README.md con ejemplos de uso de verbose y paginación
- Actualizado .gitignore para excluir scripts de prueba con API keys
2025-10-28 16:42:12 -06:00

608 lines
19 KiB
TypeScript

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: Record<string, string> = {
'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<any[]>(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<any[]>('/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<any>('/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<any[]>('/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`);
});