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

  1. Lo más importante arriba. El resumen ejecutivo va primero — el developer decide en 2 segundos si necesita profundizar.
  2. Secciones independientes. Cada sección debe ser comprensible sin leer las demás.
  3. Alertas visibles. Si algo requiere acción, debe ser obvio (emojis rojos, sección dedicada).
  4. Acciones claras. El footer sugiere qué hacer después — qué tool invocar para más detalle.
  5. 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

  1. Node.js os Module — API de sistema operativo para dashboards de sistema
  2. Python sqlite3 Module — Interacción con SQLite
  3. MCP TypeScript SDK — Tools — Referencia de tools
  4. Unicode Block Elements — Caracteres para barras y charts
  5. 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.