Implementar parámetro verbose y paginación en MCP Metabase
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
This commit is contained in:
2025-10-28 16:42:12 -06:00
parent 834682519a
commit 4cbaee3fbd
6 changed files with 992 additions and 23 deletions

View File

@@ -47,6 +47,88 @@ async function metabaseFetch<T = any>(
}
}
// ==================== 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',
@@ -60,19 +142,33 @@ server.registerTool(
'metabase_cards',
{
title: 'Listar o buscar Cards/Questions',
description: 'Lista todas las cards o busca cards por nombre/colección',
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()),
count: z.number(),
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 }) => {
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();
@@ -86,6 +182,7 @@ server.registerTool(
const cards = await metabaseFetch<any[]>(endpoint);
// Aplicar filtro de búsqueda
let filteredCards = cards;
if (action === 'search' && query) {
const searchLower = query.toLowerCase();
@@ -95,9 +192,26 @@ server.registerTool(
);
}
// 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: filteredCards,
count: filteredCards.length,
cards: paginatedCards,
pagination: {
page: validPage,
pageSize: validPageSize,
totalItems: totalCards,
totalPages: totalPages,
hasNextPage: validPage < totalPages,
hasPreviousPage: validPage > 1,
}
};
return {
@@ -299,18 +413,24 @@ server.registerTool(
'metabase_collections',
{
title: 'Listar Colecciones',
description: 'Lista todas las colecciones disponibles en Metabase',
inputSchema: {},
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 () => {
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(collections, null, 2) }],
structuredContent: { collections }
content: [{ type: 'text', text: JSON.stringify(filteredCollections, null, 2) }],
structuredContent: { collections: filteredCollections }
};
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Error desconocido';
@@ -327,27 +447,31 @@ server.registerTool(
'metabase_databases',
{
title: 'Listar Bases de Datos',
description: 'Lista todas las bases de datos configuradas en Metabase',
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 ({ include_tables }) => {
async ({ verbose = false, include_tables = false }) => {
try {
const databases = await metabaseFetch<any[]>('/api/database');
// 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) => {
databases.map(async (db: any) => {
try {
const metadata = await metabaseFetch(`/api/database/${db.id}/metadata`);
return { ...db, metadata };
const filtered = filterDatabaseFields(db, verbose);
return { ...filtered, metadata };
} catch (error) {
console.error(`Error obteniendo metadata de DB ${db.id}:`, error);
return db;
return filterDatabaseFields(db, verbose);
}
})
);
@@ -357,9 +481,12 @@ server.registerTool(
};
}
// Filtrar campos según verbose
const filteredDatabases = databases.map((db: any) => filterDatabaseFields(db, verbose));
return {
content: [{ type: 'text', text: JSON.stringify(databases, null, 2) }],
structuredContent: { databases }
content: [{ type: 'text', text: JSON.stringify(filteredDatabases, null, 2) }],
structuredContent: { databases: filteredDatabases }
};
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Error desconocido';