Implementar parámetro verbose y paginación en MCP Metabase
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 21s
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:
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user