Corregir implementación MCP Metabase con API correcta
Some checks failed
build-and-deploy / build-and-deploy (push) Failing after 12s

- 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<string, string>
- Soluciona errores de TypeScript en el build
This commit is contained in:
2025-10-28 11:47:46 -06:00
parent 6de07392fb
commit acb73401aa

View File

@@ -20,12 +20,15 @@ async function metabaseFetch<T = any>(
): Promise<T> { ): Promise<T> {
const url = `${METABASE_URL}${endpoint}`; const url = `${METABASE_URL}${endpoint}`;
const headers: HeadersInit = { const headers: Record<string, string> = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-API-KEY': METABASE_API_KEY, 'X-API-KEY': METABASE_API_KEY,
...options.headers,
}; };
if (options.headers) {
Object.assign(headers, options.headers);
}
try { try {
const response = await fetch(url, { const response = await fetch(url, {
...options, ...options,
@@ -53,268 +56,362 @@ const server = new McpServer({
// ==================== Herramientas ==================== // ==================== Herramientas ====================
// 1. Listar/buscar cards // 1. Listar/buscar cards
server.registerTool({ server.registerTool(
name: 'metabase_cards', 'metabase_cards',
metadata: { {
title: 'Listar o buscar Cards/Questions', 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',
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({ async ({ action, query, collection_id }) => {
action: z.enum(['list', 'search']).describe('Acción a realizar'), try {
query: z.string().optional().describe('Término de búsqueda (para action=search)'), let endpoint = '/api/card';
collection_id: z.number().optional().describe('ID de colección para filtrar'), const params = new URLSearchParams();
}),
outputSchema: z.object({
cards: z.array(z.any()),
count: z.number(),
}),
handler: async (input) => {
const { action, query, collection_id } = input;
let endpoint = '/api/card'; if (collection_id !== undefined) {
const params = new URLSearchParams(); params.append('collection', collection_id.toString());
}
if (collection_id !== undefined) { if (params.toString()) {
params.append('collection', collection_id.toString()); endpoint += `?${params.toString()}`;
}
const cards = await metabaseFetch<any[]>(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<any[]>(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 // 2. Obtener detalles de una card
server.registerTool({ server.registerTool(
name: 'metabase_card_info', 'metabase_card_info',
metadata: { {
title: 'Obtener detalles de una Card', title: 'Obtener detalles de una Card',
description: 'Obtiene información detallada de una card específica por su ID', 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({ async ({ card_id }) => {
card_id: z.number().describe('ID de la card'), try {
}), const card = await metabaseFetch(`/api/card/${card_id}`);
outputSchema: z.any(), return {
handler: async (input) => { content: [{ type: 'text', text: JSON.stringify(card, null, 2) }],
const { card_id } = input; structuredContent: card
const card = await metabaseFetch(`/api/card/${card_id}`); };
return 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 // 3. Ejecutar card con parámetros
server.registerTool({ server.registerTool(
name: 'metabase_execute_card', 'metabase_execute_card',
metadata: { {
title: 'Ejecutar Card con parámetros', title: 'Ejecutar Card con parámetros',
description: 'Ejecuta una card/question existente, opcionalmente 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({ async ({ card_id, parameters }) => {
card_id: z.number().describe('ID de la card a ejecutar'), try {
parameters: z.array(z.object({ const body: any = {};
type: z.string().describe('Tipo de parámetro (ej: date/single, category, etc.)'), if (parameters && parameters.length > 0) {
target: z.any().describe('Target del parámetro'), body.parameters = parameters;
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;
const body: any = {}; const result = await metabaseFetch(`/api/card/${card_id}/query`, {
if (parameters && parameters.length > 0) { method: 'POST',
body.parameters = parameters; 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 // 4. Crear nueva card
server.registerTool({ server.registerTool(
name: 'metabase_create_card', 'metabase_create_card',
metadata: { {
title: 'Crear nueva Card', title: 'Crear nueva Card',
description: 'Crea una nueva card/question en Metabase', 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({ async ({ name, description, dataset_query, display, visualization_settings, collection_id }) => {
name: z.string().describe('Nombre de la card'), try {
description: z.string().optional().describe('Descripción de la card'), const body: any = {
dataset_query: z.object({ name,
type: z.enum(['native', 'query']).describe('Tipo de query'), dataset_query,
database: z.number().describe('ID de la base de datos'), display: display || 'table',
native: z.object({ visualization_settings: visualization_settings || {},
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 || {},
};
if (input.description) { if (description) {
body.description = input.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) { // 5. Actualizar card
body.collection_id = input.collection_id; server.registerTool(
} 'metabase_update_card',
{
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: {
title: 'Actualizar Card', title: 'Actualizar Card',
description: 'Actualiza el nombre y/o descripción de una card existente', 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({ async ({ card_id, name, description }) => {
card_id: z.number().describe('ID de la card a actualizar'), try {
name: z.string().optional().describe('Nuevo nombre de la card'), if (!name && !description) {
description: z.string().optional().describe('Nueva descripción de la card'), throw new Error('Debe proporcionar al menos name o description');
}), }
outputSchema: z.any(),
handler: async (input) => {
const { card_id, name, description } = input;
if (!name && !description) { const body: any = {};
throw new Error('Debe proporcionar al menos name o description para actualizar'); 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 // 6. Listar colecciones
server.registerTool({ server.registerTool(
name: 'metabase_collections', 'metabase_collections',
metadata: { {
title: 'Listar Colecciones', title: 'Listar Colecciones',
description: 'Lista todas las colecciones disponibles en Metabase', description: 'Lista todas las colecciones disponibles en Metabase',
inputSchema: {},
outputSchema: z.array(z.any())
}, },
inputSchema: z.object({}), async () => {
outputSchema: z.array(z.any()), try {
handler: async () => { const collections = await metabaseFetch<any[]>('/api/collection');
const collections = await metabaseFetch<any[]>('/api/collection'); return {
return collections; 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 // 7. Listar bases de datos
server.registerTool({ server.registerTool(
name: 'metabase_databases', 'metabase_databases',
metadata: { {
title: 'Listar Bases de Datos', 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',
inputSchema: {
include_tables: z.boolean().optional().default(false).describe('Incluir metadata de tablas'),
},
outputSchema: z.any()
}, },
inputSchema: z.object({ async ({ include_tables }) => {
include_tables: z.boolean().optional().default(false).describe('Incluir metadatos de tablas y campos'), try {
}), const databases = await metabaseFetch<any[]>('/api/database');
outputSchema: z.any(),
handler: async (input) => {
const { include_tables } = input;
const databases = await metabaseFetch<any[]>('/api/database');
if (include_tables) { if (include_tables) {
// Obtener metadata completa de cada base de datos const databasesWithMetadata = await Promise.all(
const databasesWithMetadata = await Promise.all( databases.map(async (db) => {
databases.map(async (db) => { try {
try { const metadata = await metabaseFetch(`/api/database/${db.id}/metadata`);
const metadata = await metabaseFetch(`/api/database/${db.id}/metadata`); return { ...db, metadata };
return { ...db, metadata }; } catch (error) {
} catch (error) { console.error(`Error obteniendo metadata de DB ${db.id}:`, error);
console.error(`Error obteniendo metadata de DB ${db.id}:`, error); return db;
return db; }
} })
}) );
); return {
return databasesWithMetadata; 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 // 8. Listar dashboards
server.registerTool({ server.registerTool(
name: 'metabase_dashboards', 'metabase_dashboards',
metadata: { {
title: 'Listar Dashboards', title: 'Listar Dashboards',
description: 'Lista todos los dashboards disponibles', description: 'Lista todos los dashboards disponibles',
inputSchema: {},
outputSchema: z.array(z.any())
}, },
inputSchema: z.object({}), async () => {
outputSchema: z.array(z.any()), try {
handler: async () => { const dashboards = await metabaseFetch<any[]>('/api/dashboard');
const dashboards = await metabaseFetch<any[]>('/api/dashboard'); return {
return dashboards; 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 // 9. Obtener detalles de un dashboard
server.registerTool({ server.registerTool(
name: 'metabase_dashboard_info', 'metabase_dashboard_info',
metadata: { {
title: 'Obtener detalles de Dashboard', title: 'Obtener detalles de Dashboard',
description: 'Obtiene información detallada de un dashboard específico incluyendo sus cards', 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({ async ({ dashboard_id }) => {
dashboard_id: z.number().describe('ID del dashboard'), try {
}), const dashboard = await metabaseFetch(`/api/dashboard/${dashboard_id}`);
outputSchema: z.any(), return {
handler: async (input) => { content: [{ type: 'text', text: JSON.stringify(dashboard, null, 2) }],
const { dashboard_id } = input; structuredContent: dashboard
const dashboard = await metabaseFetch(`/api/dashboard/${dashboard_id}`); };
return dashboard; } catch (error) {
}, const errorMsg = error instanceof Error ? error.message : 'Error desconocido';
}); return {
content: [{ type: 'text', text: `Error: ${errorMsg}` }],
isError: true
};
}
}
);
// ==================== Servidor Express ==================== // ==================== Servidor Express ====================
const app = express(); const app = express();
@@ -332,12 +429,31 @@ app.get('/health', (req, res) => {
// Endpoint MCP (Traefik hace StripPrefix de /mcp) // Endpoint MCP (Traefik hace StripPrefix de /mcp)
app.post('/', async (req, res) => { app.post('/', async (req, res) => {
const transport = new StreamableHTTPServerTransport({ try {
endpoint: '/', const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => Math.random().toString(36).substring(7), 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 // Iniciar servidor