Módulo 6: MCP Apps y UI Interactivo
Dashboards y Visualizaciones como Output de Tools
Dashboards y Visualizaciones como Output de Tools
Descripción de la cápsula
En la cápsula anterior aprendiste los building blocks: tipos de contenido, patrones de formateo, y helpers para convertir datos crudos en output visual. Ahora vamos a aplicar todo eso para construir algo concreto: dashboards completos que un developer usaría en su día a día.
Un dashboard no es una tabla aislada. Es una composición: un header con resumen ejecutivo, secciones temáticas con datos formateados, indicadores de salud, y sugerencias de acción. Esta cápsula te enseña a construir dashboards de tres tipos: estado de proyecto, analytics de base de datos, y monitor de sistema. Cada uno demuestra un patrón de composición diferente.
Al terminar, tendrás implementaciones completas en TypeScript y Python que puedes copiar, adaptar, y conectar a tus propios datos.
Arquitectura de un dashboard MCP
La estructura que funciona
Después de experimentar con diferentes formatos, este es el layout que mejor funciona para dashboards en MCP:
┌─────────────────────────────────────────────┐
│ HEADER: Título + timestamp + resumen │
│ "📊 Project Dashboard — 142 files, 98% ✅" │
├─────────────────────────────────────────────┤
│ MÉTRICAS CLAVE: 3-5 KPIs en una línea │
│ Files: 142 | Tests: 98% | Coverage: 84% │
├─────────────────────────────────────────────┤
│ SECCIÓN 1: Tabla de datos principales │
│ (la información más importante primero) │
├─────────────────────────────────────────────┤
│ SECCIÓN 2: Tabla/chart secundario │
│ (complementa la sección principal) │
├─────────────────────────────────────────────┤
│ ALERTAS: Problemas detectados │
│ (solo si hay algo que requiere atención) │
├─────────────────────────────────────────────┤
│ FOOTER: Acciones sugeridas + tips │
│ "Usa drill_down('tests') para detalle" │
└─────────────────────────────────────────────┘
Principios de diseño
- Lo más importante arriba. El resumen ejecutivo va primero — el developer decide en 2 segundos si necesita profundizar.
- Secciones independientes. Cada sección debe ser comprensible sin leer las demás.
- Alertas visibles. Si algo requiere acción, debe ser obvio (emojis rojos, sección dedicada).
- Acciones claras. El footer sugiere qué hacer después — qué tool invocar para más detalle.
- Datos, no ruido. Si un dato no ayuda a tomar una decisión, no lo incluyas.
Dashboard 1: Project Status Dashboard
Un dashboard que analiza un directorio de proyecto y muestra métricas clave.
TypeScript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import * as fs from "fs/promises";
import * as path from "path";
import { execSync } from "child_process";
const server = new McpServer({ name: "project-dashboard", version: "1.0.0" });
const SKIP_DIRS = new Set(["node_modules", ".git", "__pycache__", ".venv", "dist", "build"]);
async function scanDirectory(dir: string) {
const filesByExt: Record<string, number> = {};
const allFiles: { path: string; size: number; modified: number }[] = [];
let totalSize = 0;
async function walk(currentDir: string) {
const entries = await fs.readdir(currentDir, { withFileTypes: true });
for (const entry of entries) {
if (SKIP_DIRS.has(entry.name)) continue;
const fullPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
await walk(fullPath);
} else {
const stats = await fs.stat(fullPath);
const ext = path.extname(entry.name) || "(none)";
filesByExt[ext] = (filesByExt[ext] || 0) + 1;
totalSize += stats.size;
allFiles.push({
path: path.relative(dir, fullPath),
size: stats.size,
modified: stats.mtimeMs,
});
}
}
}
await walk(dir);
return { filesByExt, allFiles, totalSize };
}
function sizeStr(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function timeAgo(ms: number): string {
const diff = Date.now() - ms;
const minutes = Math.floor(diff / 60000);
if (minutes < 60) return `hace ${minutes}min`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `hace ${hours}h`;
return `hace ${Math.floor(hours / 24)}d`;
}
function progressBar(value: number, max: number, width = 15): string {
const filled = max > 0 ? Math.round((value / max) * width) : 0;
return "█".repeat(filled) + "░".repeat(width - filled);
}
server.tool(
"project_dashboard",
"Muestra un dashboard completo del estado de un proyecto: archivos, tamaños, actividad reciente",
{
directory: z.string().describe("Directorio raíz del proyecto"),
topN: z.number().int().positive().default(8).describe("Cantidad de items en cada sección"),
},
async ({ directory, topN }) => {
try {
await fs.access(directory);
} catch {
return {
content: [{ type: "text" as const, text: `Error: directorio '${directory}' no accesible` }],
isError: true,
};
}
const { filesByExt, allFiles, totalSize } = await scanDirectory(directory);
const totalFiles = allFiles.length;
const sortedExts = Object.entries(filesByExt).sort(([, a], [, b]) => b - a);
const maxExtCount = sortedExts[0]?.[1] || 1;
const largest = [...allFiles].sort((a, b) => b.size - a.size).slice(0, topN);
const recent = [...allFiles].sort((a, b) => b.modified - a.modified).slice(0, topN);
let gitInfo = "";
try {
const log = execSync("git log --oneline -5 2>/dev/null", { cwd: directory }).toString().trim();
if (log) {
gitInfo = "\n---\n\n### 📝 Commits recientes\n\n";
for (const line of log.split("\n")) {
gitInfo += `- \`${line}\`\n`;
}
}
} catch { /* not a git repo */ }
let dashboard = `## 📊 Project Dashboard
**Directorio:** \`${directory}\`
**Resumen:** ${totalFiles} archivos | ${sizeStr(totalSize)} | ${sortedExts.length} tipos de archivo
---
### 📁 Distribución por tipo
| Extensión | Archivos | Distribución |
|-----------|----------|-------------|
`;
for (const [ext, count] of sortedExts.slice(0, topN)) {
const bar = progressBar(count, maxExtCount);
const pct = ((count / totalFiles) * 100).toFixed(1);
dashboard += `| \`${ext}\` | ${count} (${pct}%) | ${bar} |\n`;
}
if (sortedExts.length > topN) {
const others = sortedExts.slice(topN).reduce((sum, [, c]) => sum + c, 0);
dashboard += `| *otros* | ${others} | — |\n`;
}
dashboard += `
---
### 📏 Archivos más grandes
| Archivo | Tamaño |
|---------|--------|
`;
for (const f of largest) {
dashboard += `| \`${f.path}\` | ${sizeStr(f.size)} |\n`;
}
dashboard += `
---
### 🕐 Actividad reciente
| Archivo | Última modificación |
|---------|-------------------|
`;
for (const f of recent) {
dashboard += `| \`${f.path}\` | ${timeAgo(f.modified)} |\n`;
}
dashboard += gitInfo;
dashboard += `
---
*Tip: Usa este dashboard regularmente para detectar archivos que crecen sin control o áreas del proyecto sin actividad.*`;
return { content: [{ type: "text" as const, text: dashboard }] };
}
);
const transport = new StdioServerTransport();
await server.connect(transport);
Dashboard 2: Database Analytics View
Un dashboard que muestra estadísticas de una base de datos SQLite.
Python
import sqlite3
import os
from datetime import datetime
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("db-analytics")
def connect_db(db_path: str) -> sqlite3.Connection:
if not os.path.exists(db_path):
raise FileNotFoundError(f"Database no encontrada: {db_path}")
return sqlite3.connect(db_path)
@mcp.tool()
async def db_dashboard(db_path: str) -> str:
"""Dashboard de analytics para una base de datos SQLite."""
try:
conn = connect_db(db_path)
except FileNotFoundError as e:
return f"Error: {e}"
cursor = conn.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
tables = [row[0] for row in cursor.fetchall()]
if not tables:
conn.close()
return f"## 📊 DB Dashboard\n\n**Database:** `{db_path}`\n\n*Base de datos vacía — no hay tablas.*"
table_stats = []
total_rows = 0
for table in tables:
cursor.execute(f"SELECT COUNT(*) FROM [{table}]")
row_count = cursor.fetchone()[0]
cursor.execute(f"PRAGMA table_info([{table}])")
columns = cursor.fetchall()
col_count = len(columns)
col_names = [col[1] for col in columns[:5]]
total_rows += row_count
table_stats.append({
"name": table,
"rows": row_count,
"columns": col_count,
"column_names": col_names,
})
db_size = os.path.getsize(db_path)
size_str = f"{db_size / 1024:.1f} KB" if db_size < 1024 * 1024 else f"{db_size / 1024 / 1024:.1f} MB"
max_rows = max(t["rows"] for t in table_stats) if table_stats else 1
dashboard = f"""## 📊 Database Analytics
**Database:** `{db_path}`
**Tamaño:** {size_str} | **Tablas:** {len(tables)} | **Total filas:** {total_rows:,}
---
### 📋 Tablas
| Tabla | Filas | Columnas | Distribución |
|-------|-------|----------|-------------|
"""
for t in sorted(table_stats, key=lambda x: -x["rows"]):
bar_len = int(t["rows"] / max_rows * 12) if max_rows > 0 else 0
bar = "█" * bar_len + "░" * (12 - bar_len)
dashboard += f"| `{t['name']}` | {t['rows']:,} | {t['columns']} | {bar} |\n"
dashboard += "\n---\n\n### 🔍 Detalle de columnas\n\n"
for t in table_stats[:6]:
cols_str = ", ".join(f"`{c}`" for c in t["column_names"])
extra = f" (+{t['columns'] - 5} más)" if t["columns"] > 5 else ""
dashboard += f"- **{t['name']}**: {cols_str}{extra}\n"
empty_tables = [t for t in table_stats if t["rows"] == 0]
if empty_tables:
dashboard += "\n---\n\n### ⚠️ Tablas vacías\n\n"
for t in empty_tables:
dashboard += f"- `{t['name']}` ({t['columns']} columnas definidas, 0 filas)\n"
large_tables = [t for t in table_stats if t["rows"] > 10000]
if large_tables:
dashboard += "\n---\n\n### 📈 Tablas grandes (>10K filas)\n\n"
for t in large_tables:
dashboard += f"- `{t['name']}` — **{t['rows']:,}** filas\n"
dashboard += f"\n---\n\n*Timestamp: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*"
conn.close()
return dashboard
@mcp.tool()
async def table_detail(db_path: str, table_name: str, sample_rows: int = 5) -> str:
"""Detalle de una tabla específica con schema y muestra de datos."""
try:
conn = connect_db(db_path)
except FileNotFoundError as e:
return f"Error: {e}"
cursor = conn.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table_name,))
if not cursor.fetchone():
conn.close()
return f"Error: tabla '{table_name}' no encontrada"
cursor.execute(f"PRAGMA table_info([{table_name}])")
columns = cursor.fetchall()
cursor.execute(f"SELECT COUNT(*) FROM [{table_name}]")
total_rows = cursor.fetchone()[0]
cursor.execute(f"SELECT * FROM [{table_name}] LIMIT {sample_rows}")
rows = cursor.fetchall()
col_names = [col[1] for col in columns]
detail = f"""## 🔎 Tabla: `{table_name}`
**Filas:** {total_rows:,} | **Columnas:** {len(columns)}
---
### Schema
| Columna | Tipo | Nullable | PK |
|---------|------|----------|-----|
"""
for col in columns:
_, name, type_, notnull, default, pk = col
nullable = "❌" if notnull else "✅"
is_pk = "🔑" if pk else ""
detail += f"| `{name}` | {type_ or 'ANY'} | {nullable} | {is_pk} |\n"
if rows:
detail += f"\n---\n\n### Muestra ({min(sample_rows, len(rows))} filas)\n\n"
detail += "| " + " | ".join(f"`{c}`" for c in col_names) + " |\n"
detail += "| " + " | ".join("---" for _ in col_names) + " |\n"
for row in rows:
values = [str(v)[:30] if v is not None else "*NULL*" for v in row]
detail += "| " + " | ".join(values) + " |\n"
else:
detail += "\n*Tabla vacía — sin datos para mostrar.*\n"
conn.close()
return detail
if __name__ == "__main__":
mcp.run()
Patrón avanzado: Drill-down
Un dashboard efectivo permite "hacer zoom" — del resumen general al detalle de un área específica. Implementas esto con múltiples tools que se referencian entre sí:
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("analytics-drilldown")
@mcp.tool()
async def overview() -> str:
"""Dashboard resumen — vista de alto nivel."""
return """## 📊 Overview
| Área | Status | Métrica clave |
|------|--------|--------------|
| Tests | ✅ 142 passing | 98.6% pass rate |
| Coverage | 🟡 78.3% | Target: 80% |
| Performance | ✅ p95: 230ms | Budget: 500ms |
| Dependencies | ⚠️ 3 outdated | 2 con vulnerabilidades |
---
**Drill-down disponible:**
- `drill_down("tests")` — Detalle de test suite
- `drill_down("coverage")` — Coverage por archivo
- `drill_down("deps")` — Dependencias desactualizadas
"""
@mcp.tool()
async def drill_down(area: str) -> str:
"""Muestra detalle de un área específica del dashboard."""
if area == "tests":
return """## 🧪 Test Suite — Detalle
| Suite | Tests | Pass | Fail | Skip | Duración |
|-------|-------|------|------|------|----------|
| Unit | 98 | 97 | 1 | 0 | 4.2s |
| Integration | 34 | 34 | 0 | 0 | 12.8s |
| E2E | 10 | 9 | 0 | 1 | 45.3s |
### ❌ Tests fallando
- `unit/auth.test.ts` > "should reject expired tokens" — AssertionError: expected 401, got 200
### ⏭️ Tests skipped
- `e2e/payment.test.ts` > "process refund" — TODO: mock payment gateway
---
*Vuelve al overview con `overview()`*
"""
elif area == "coverage":
return """## 📊 Coverage — Por archivo
| Archivo | Stmts | Branches | Lines |
|---------|-------|----------|-------|
| src/auth.ts | 🟢 95% | 🟢 90% | 🟢 95% |
| src/api.ts | 🟢 88% | 🟡 75% | 🟢 87% |
| src/db.ts | 🟡 72% | 🔴 55% | 🟡 71% |
🔴 **src/db.ts** — branches 55%. Faltan tests para error paths.
*Vuelve al overview con `overview()`*"""
elif area == "deps":
return """## 📦 Dependencias
| Paquete | Actual | Última | Notas |
|---------|--------|--------|-------|
| express | 4.18.2 | 4.21.0 | ⚠️ vuln low |
| lodash | 4.17.20 | 4.17.21 | 🔴 vuln medium |
| typescript | 5.2.2 | 5.6.3 | minor update |
*Vuelve al overview con `overview()`*"""
return f"Área '{area}' no reconocida. Opciones: tests, coverage, deps"
if __name__ == "__main__":
mcp.run()
El patrón drill-down convierte tu MCP App en una herramienta de navegación: el usuario ve el resumen, identifica un área de interés, y pide detalle. Es el equivalente de hacer click en una sección de un dashboard web.
Ejercicios
Ejercicio 1: Dashboard de Git (Fácil)
Crea un tool git_dashboard que ejecute comandos git y presente un dashboard con: branch actual, últimos 5 commits, archivos modificados, y estado del working tree.
Ver solución
import { execSync } from "child_process";
server.tool(
"git_dashboard",
"Dashboard del estado de un repositorio Git",
{
repoPath: z.string().describe("Ruta al repositorio"),
},
async ({ repoPath }) => {
const run = (cmd: string) => {
try { return execSync(cmd, { cwd: repoPath }).toString().trim(); }
catch { return ""; }
};
const branch = run("git branch --show-current");
const log = run("git log --oneline -5");
const status = run("git status --short");
const remoteStatus = run("git status --branch --porcelain=v2 | head -3");
const statusLines = status ? status.split("\n") : [];
const modified = statusLines.filter(l => l.startsWith(" M") || l.startsWith("M "));
const added = statusLines.filter(l => l.startsWith("A ") || l.startsWith("??"));
const deleted = statusLines.filter(l => l.startsWith("D ") || l.startsWith(" D"));
let dashboard = `## 🔀 Git Dashboard
**Branch:** \`${branch || "detached"}\`
**Working tree:** ${statusLines.length === 0 ? "✅ Limpio" : `⚠️ ${statusLines.length} cambios`}
---
### 📝 Últimos commits
`;
if (log) {
for (const line of log.split("\n")) {
dashboard += `- \`${line}\`\n`;
}
} else {
dashboard += "*Sin commits*\n";
}
if (statusLines.length > 0) {
dashboard += `\n---\n\n### 📋 Cambios pendientes\n\n`;
dashboard += `| Tipo | Cantidad |\n|------|----------|\n`;
if (modified.length) dashboard += `| Modificados | ${modified.length} |\n`;
if (added.length) dashboard += `| Nuevos/Untracked | ${added.length} |\n`;
if (deleted.length) dashboard += `| Eliminados | ${deleted.length} |\n`;
dashboard += `\n**Archivos:**\n\n`;
for (const line of statusLines.slice(0, 10)) {
dashboard += `- \`${line}\`\n`;
}
if (statusLines.length > 10) {
dashboard += `\n*...y ${statusLines.length - 10} más*\n`;
}
}
return { content: [{ type: "text" as const, text: dashboard }] };
}
);
Ejercicio 2: Dashboard de API endpoints (Medio)
Crea un tool Python api_dashboard que reciba una lista de endpoints con sus métricas (path, method, avg_latency_ms, requests_24h, error_rate) y genere un dashboard con tabla ordenada por error rate, alertas para endpoints con >5% errors, y un resumen de salud.
Ver solución
from mcp.server.fastmcp import FastMCP
from pydantic import BaseModel, Field
mcp = FastMCP("api-dashboard")
class EndpointMetric(BaseModel):
path: str
method: str = "GET"
avg_latency_ms: float
requests_24h: int
error_rate: float = Field(ge=0, le=100, description="Error rate en %")
@mcp.tool()
async def api_dashboard(endpoints: list[EndpointMetric]) -> str:
"""Dashboard de salud de API endpoints."""
if not endpoints:
return "## 📡 API Dashboard\n\n*Sin endpoints para analizar*"
total_req = sum(e.requests_24h for e in endpoints)
avg_latency = sum(e.avg_latency_ms for e in endpoints) / len(endpoints)
healthy = sum(1 for e in endpoints if e.error_rate < 1)
degraded = sum(1 for e in endpoints if 1 <= e.error_rate <= 5)
unhealthy = sum(1 for e in endpoints if e.error_rate > 5)
def status_emoji(rate: float) -> str:
if rate < 1: return "✅"
if rate <= 5: return "🟡"
return "🔴"
def latency_indicator(ms: float) -> str:
if ms < 100: return "⚡"
if ms < 500: return "✅"
if ms < 2000: return "🟡"
return "🐌"
sorted_eps = sorted(endpoints, key=lambda e: -e.error_rate)
lines = [
"## 📡 API Health Dashboard",
"",
f"**Endpoints:** {len(endpoints)} | ✅ {healthy} healthy | 🟡 {degraded} degraded | 🔴 {unhealthy} unhealthy",
f"**Requests (24h):** {total_req:,} | **Avg latency:** {avg_latency:.0f}ms",
"",
"---",
"",
"| Endpoint | Method | Latency | Req/24h | Errors | Status |",
"|----------|--------|---------|---------|--------|--------|",
]
for e in sorted_eps:
lines.append(
f"| `{e.path}` | {e.method} | {latency_indicator(e.avg_latency_ms)} {e.avg_latency_ms:.0f}ms "
f"| {e.requests_24h:,} | {e.error_rate:.1f}% | {status_emoji(e.error_rate)} |"
)
alerts = [e for e in endpoints if e.error_rate > 5]
if alerts:
lines.extend(["", "---", "", "### 🔴 Requieren atención", ""])
for e in alerts:
lines.append(
f"- **{e.method} {e.path}** — {e.error_rate:.1f}% error rate "
f"({int(e.requests_24h * e.error_rate / 100):,} errores estimados en 24h)"
)
slow = [e for e in endpoints if e.avg_latency_ms > 1000]
if slow:
lines.extend(["", "---", "", "### 🐌 Latencia alta (>1s)", ""])
for e in slow:
lines.append(f"- **{e.method} {e.path}** — {e.avg_latency_ms:.0f}ms promedio")
return "\n".join(lines)
if __name__ == "__main__":
mcp.run()
Ejercicio 3: Drill-down de dependencias (Medio)
Crea dos tools: deps_overview que lea un package.json y muestre un resumen de dependencias (total, por tipo), y deps_detail que muestre el detalle de una dependencia específica (versión instalada, si es dev, descripción).
Ver solución
import * as fs from "fs/promises";
import * as path from "path";
server.tool(
"deps_overview",
"Resumen de dependencias de un proyecto Node.js",
{ projectDir: z.string().describe("Directorio del proyecto") },
async ({ projectDir }) => {
const pkgPath = path.join(projectDir, "package.json");
try {
const raw = await fs.readFile(pkgPath, "utf-8");
const pkg = JSON.parse(raw);
const deps = Object.keys(pkg.dependencies || {});
const devDeps = Object.keys(pkg.devDependencies || {});
let out = `## 📦 Dependencias — ${pkg.name || "proyecto"}\n\n`;
out += `**Total:** ${deps.length + devDeps.length} | Production: ${deps.length} | Dev: ${devDeps.length}\n\n---\n\n`;
out += "### Production\n\n| Paquete | Versión |\n|---------|--------|\n";
for (const dep of deps.slice(0, 15)) {
out += `| \`${dep}\` | ${pkg.dependencies[dep]} |\n`;
}
if (deps.length > 15) out += `\n*...y ${deps.length - 15} más*\n`;
out += "\n### Dev\n\n| Paquete | Versión |\n|---------|--------|\n";
for (const dep of devDeps.slice(0, 10)) {
out += `| \`${dep}\` | ${pkg.devDependencies[dep]} |\n`;
}
if (devDeps.length > 10) out += `\n*...y ${devDeps.length - 10} más*\n`;
out += `\n---\n\n*Usa \`deps_detail\` para ver info de una dependencia específica.*`;
return { content: [{ type: "text" as const, text: out }] };
} catch {
return { content: [{ type: "text" as const, text: `Error: no se pudo leer ${pkgPath}` }], isError: true };
}
}
);
Ejercicio 4: Dashboard con sparklines (Difícil)
Crea un tool Python metrics_trend que reciba métricas con valores históricos (últimos 7 días) y muestre un dashboard con sparklines, tendencias, y comparación semana anterior.
Ver solución
from mcp.server.fastmcp import FastMCP
from pydantic import BaseModel, Field
mcp = FastMCP("metrics-trend")
def sparkline(values: list[float]) -> str:
if not values: return ""
chars = "▁▂▃▄▅▆▇█"
mn, mx = min(values), max(values)
rng = mx - mn if mx != mn else 1
return "".join(chars[min(int((v - mn) / rng * 7), 7)] for v in values)
def trend(values: list[float]) -> str:
if len(values) < 2: return "→"
first_half = sum(values[:len(values)//2]) / (len(values)//2)
second_half = sum(values[len(values)//2:]) / (len(values) - len(values)//2)
if second_half > first_half * 1.05: return "📈 ↑"
if second_half < first_half * 0.95: return "📉 ↓"
return "➡️ →"
class MetricSeries(BaseModel):
name: str
values: list[float] = Field(min_length=2, description="Valores diarios (últimos 7 días)")
unit: str = ""
@mcp.tool()
async def metrics_trend(metrics: list[MetricSeries]) -> str:
"""Dashboard de tendencias con sparklines de 7 días."""
lines = ["## 📈 Métricas — Tendencia 7 días", "",
"| Métrica | Actual | Sparkline | Trend | Min | Max |",
"|---------|--------|-----------|-------|-----|-----|"]
for m in metrics:
current = m.values[-1]
spark = sparkline(m.values)
t = trend(m.values)
mn, mx = min(m.values), max(m.values)
lines.append(f"| {m.name} | {current:.1f}{m.unit} | {spark} | {t} | {mn:.1f} | {mx:.1f} |")
return "\n".join(lines)
if __name__ == "__main__":
mcp.run()
Troubleshooting
"El dashboard se ve desordenado cuando hay muchos datos"
Causa: Retornas demasiadas filas en las tablas sin límite.
Solución: Siempre limita la cantidad de items y agrega un indicador de truncamiento:
items = sorted(data, key=lambda x: -x["value"])[:MAX_ITEMS]
if len(data) > MAX_ITEMS:
footer = f"\n*Mostrando top {MAX_ITEMS} de {len(data)}*"
"Los emojis no se muestran correctamente"
Causa: La terminal o fuente no soporta todos los emojis Unicode.
Solución: Usa emojis básicos que tienen soporte universal: ✅ ❌ ⚠️ 📊 📁 🔴 🟢 🟡. Evita emojis complejos o combinaciones de ZWJ.
"execSync causa timeout en el dashboard"
Causa: Comandos de sistema como git log o df pueden colgar si el repositorio es muy grande o el disco está lento.
Solución:
try {
const output = execSync("git log --oneline -5", {
cwd: repoPath,
timeout: 5000,
}).toString();
} catch {
return "(git info no disponible)";
}
Resumen
En esta cápsula construiste tres tipos de dashboards:
- Project Status Dashboard — análisis de directorio con distribución de archivos, tamaños, y actividad
- Database Analytics View — estadísticas de tablas SQLite con schema y muestras de datos
- System Health Monitor — CPU, memoria, disco con barras de uso y alertas
Y aprendiste el patrón drill-down — dashboards que permiten navegar del resumen al detalle con tools que se referencian mutuamente.
Los principios clave: información importante arriba, secciones independientes, alertas visibles, acciones claras. Un dashboard no es solo datos formateados — es una herramienta de decisión.
Recursos adicionales
- Node.js os Module — API de sistema operativo para dashboards de sistema
- Python sqlite3 Module — Interacción con SQLite
- MCP TypeScript SDK — Tools — Referencia de tools
- Unicode Block Elements — Caracteres para barras y charts
- Dashboard Design Patterns — Principios de diseño de dashboards
Siguiente cápsula: Forms Interactivos — capturar datos del usuario via MCP, workflows multi-paso, confirmaciones, y cómo combinar tools con prompts para flujos interactivos.