All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 21s
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
608 lines
19 KiB
TypeScript
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`);
|
|
});
|