Módulo 6: MCP Apps y UI Interactivo

MCP Apps: Tools que Retornan Output Visual

MCP Apps: Tools que Retornan Output Visual

Descripción de la cápsula

En la introducción del módulo viste la idea general: MCP Apps son MCP servers cuyos tools retornan output visualmente rico. Ahora vamos a la parte técnica. ¿Cómo funciona esto exactamente? ¿Qué puedes retornar desde un tool? ¿Cómo decides entre texto plano, JSON, y markdown formateado?

Esta cápsula responde esas preguntas con código. Vas a ver la anatomía completa de un tool response, los tipos de contenido que puedes usar, y los patrones para transformar datos crudos en output que un developer quiere leer. Al terminar, tendrás las bases técnicas para construir dashboards y flujos interactivos en las siguientes cápsulas.


Anatomía de un tool response en MCP

La estructura base

Cada tool en MCP retorna un objeto CallToolResult. En su forma más simple:

// TypeScript
return {
  content: [
    {
      type: "text",
      text: "Operación completada exitosamente",
    },
  ],
};
# Python con FastMCP — retorno simplificado
@mcp.tool()
async def my_tool() -> str:
    """Mi tool."""
    return "Operación completada exitosamente"

El SDK de Python simplifica esto: cuando retornas un str, FastMCP lo envuelve automáticamente en { content: [{ type: "text", text: "..." }] }. En TypeScript, construyes la estructura explícitamente.

Content types disponibles

El protocolo MCP define varios tipos de content en las respuestas:

// Tipo "text" — el más usado
{
  type: "text",
  text: "Cualquier texto: plano, markdown, JSON, tablas, ASCII art..."
}

// Tipo "image" — para imágenes inline
{
  type: "image",
  data: "base64_encoded_image_data...",
  mimeType: "image/png"
}

// Tipo "resource" — referencia a un resource del server
{
  type: "resource",
  resource: {
    uri: "myserver://data/report",
    mimeType: "application/json",
    text: '{"key": "value"}'
  }
}

En la práctica, para MCP Apps, type: "text" es tu herramienta principal. La magia está en qué pones dentro del campo text — markdown formateado, tablas, reportes, y dashboards completos.

Múltiples content blocks

Un tool puede retornar múltiples bloques en su array content — por ejemplo, un bloque de texto + un bloque de imagen. En la práctica, un solo bloque text con markdown bien estructurado es suficiente para la mayoría de MCP Apps y más manejable que fragmentar el contenido.


De datos crudos a output rico: el proceso

Paso 1: Obtener los datos

Primero, tu tool obtiene datos de la fuente — database, API, filesystem, lo que sea:

@mcp.tool()
async def project_status() -> str:
    """Muestra el estado actual del proyecto."""
    # Paso 1: Obtener datos
    files = count_files_by_type("./src")
    tests = get_test_results()
    git_info = get_recent_commits(5)

Paso 2: Procesar y agregar

Transforma los datos crudos en métricas útiles:

    # Paso 2: Procesar
    total_files = sum(files.values())
    test_pass_rate = tests["passed"] / tests["total"] * 100 if tests["total"] > 0 else 0
    lines_changed_today = sum(c["lines_changed"] for c in git_info)

Paso 3: Formatear como output rico

Aquí es donde la magia sucede — construyes el output formateado:

    # Paso 3: Formatear
    dashboard = f"""## 📊 Estado del Proyecto

**Resumen:** {total_files} archivos | {tests["total"]} tests | {len(git_info)} commits recientes

---

### 📁 Archivos por tipo

| Tipo | Cantidad | % del total |
|------|----------|-------------|
"""
    for ext, count in sorted(files.items(), key=lambda x: -x[1]):
        pct = count / total_files * 100
        bar = "█" * int(pct / 5) + "░" * (20 - int(pct / 5))
        dashboard += f"| {ext} | {count} | {bar} {pct:.1f}% |\n"

    dashboard += f"""
---

### 🧪 Tests

| Métrica | Valor |
|---------|-------|
| Total | {tests["total"]} |
| Passing | ✅ {tests["passed"]} |
| Failing | ❌ {tests["failed"]} |
| Pass rate | {"🟢" if test_pass_rate > 90 else "🟡" if test_pass_rate > 70 else "🔴"} {test_pass_rate:.1f}% |

---

### 📝 Commits recientes

"""
    for commit in git_info:
        dashboard += f"- `{commit['hash'][:7]}` {commit['message']} ({commit['author']}, {commit['time_ago']})\n"

    dashboard += f"\n**Líneas cambiadas hoy:** {lines_changed_today:,}"

    return dashboard

El resultado

Claude Code recibe ese string y lo presenta como markdown renderizado. El usuario ve un dashboard organizado con tablas, barras de progreso, emojis indicadores, y secciones claras — no un blob de JSON.


Patrones de formateo

Patrón 1: Tablas markdown para datos tabulares

Las tablas markdown son el formato más versátil para datos estructurados:

function formatAsTable(
  headers: string[],
  rows: string[][],
  alignment?: ("left" | "center" | "right")[]
): string {
  const headerRow = `| ${headers.join(" | ")} |`;
  const separatorRow = `| ${headers.map((_, i) => {
    const align = alignment?.[i] || "left";
    if (align === "center") return ":---:";
    if (align === "right") return "---:";
    return "---";
  }).join(" | ")} |`;
  const dataRows = rows.map(row => `| ${row.join(" | ")} |`).join("\n");

  return `${headerRow}\n${separatorRow}\n${dataRows}`;
}

server.tool(
  "list_endpoints",
  "Lista los endpoints de la API con su estado actual",
  {},
  async () => {
    const endpoints = await getEndpoints();

    const headers = ["Endpoint", "Method", "Status", "Latency", "Req/24h"];
    const rows = endpoints.map(ep => [
      ep.path,
      ep.method,
      ep.status === "healthy" ? "✅ OK" : "⚠️ Degraded",
      `${ep.latency_ms}ms`,
      ep.requests_24h.toLocaleString(),
    ]);

    const table = formatAsTable(headers, rows);

    return {
      content: [{
        type: "text" as const,
        text: `## API Endpoints\n\n${table}\n\n**Total:** ${endpoints.length} endpoints`,
      }],
    };
  }
);

Patrón 2: Indicadores visuales con emojis y Unicode

Los emojis y caracteres Unicode son tu paleta visual en la terminal:

def status_indicator(value: float, thresholds: tuple[float, float] = (70, 90)) -> str:
    """Retorna un indicador visual basado en umbrales."""
    low, high = thresholds
    if value >= high:
        return f"🟢 {value:.1f}%"
    elif value >= low:
        return f"🟡 {value:.1f}%"
    else:
        return f"🔴 {value:.1f}%"


def progress_bar(current: int, total: int, width: int = 20) -> str:
    """Genera una barra de progreso ASCII."""
    if total == 0:
        return "░" * width + " 0%"
    filled = int(current / total * width)
    bar = "█" * filled + "░" * (width - filled)
    pct = current / total * 100
    return f"{bar} {pct:.0f}%"


def trend_arrow(current: float, previous: float) -> str:
    """Indica tendencia con flechas."""
    if current > previous * 1.05:
        return f"↑ +{((current - previous) / previous * 100):.1f}%"
    elif current < previous * 0.95:
        return f"↓ {((current - previous) / previous * 100):.1f}%"
    else:
        return "→ estable"

Estos helpers transforman números abstractos en información visual inmediata.

Patrón 3: Secciones con headers y separadores

Organiza dashboards largos con un helper que estandarice el formato de cada sección:

def format_section(title: str, content: str, emoji: str = "📋") -> str:
    return f"\n### {emoji} {title}\n\n{content}\n\n---"

Esto te permite construir dashboards multi-sección componiendo secciones independientes, cada una con su propio emoji, título, y contenido. Un header general con resumen + secciones individuales + un footer con acciones sugeridas es la estructura que mejor funciona.

Patrón 4: ASCII charts para visualización rápida

Cuando necesitas una visualización pero no puedes renderizar gráficos:

function asciiBarChart(
  data: { label: string; value: number }[],
  maxWidth: number = 30
): string {
  const maxValue = Math.max(...data.map(d => d.value));
  const maxLabelLen = Math.max(...data.map(d => d.label.length));

  return data.map(({ label, value }) => {
    const barLength = maxValue > 0 ? Math.round((value / maxValue) * maxWidth) : 0;
    const bar = "█".repeat(barLength) + "░".repeat(maxWidth - barLength);
    const paddedLabel = label.padEnd(maxLabelLen);
    return `${paddedLabel} ${bar} ${value.toLocaleString()}`;
  }).join("\n");
}

// Uso:
const chart = asciiBarChart([
  { label: "TypeScript", value: 45 },
  { label: "Python", value: 32 },
  { label: "Markdown", value: 18 },
  { label: "JSON", value: 12 },
  { label: "YAML", value: 5 },
]);

// Output:
// TypeScript  ██████████████████████████████ 45
// Python      █████████████████████▒░░░░░░░░ 32
// Markdown    ████████████░░░░░░░░░░░░░░░░░░ 18
// JSON        ████████░░░░░░░░░░░░░░░░░░░░░░ 12
// YAML        ███░░░░░░░░░░░░░░░░░░░░░░░░░░░  5

En Python puedes usar los mismos caracteres de bloque. Un helper útil adicional es el sparkline — una tendencia visual compacta:

def ascii_sparkline(values: list[float]) -> str:
    """Genera un sparkline con caracteres Unicode."""
    if not values:
        return ""
    chars = "▁▂▃▄▅▆▇█"
    min_val, max_val = min(values), max(values)
    range_val = max_val - min_val if max_val != min_val else 1
    return "".join(chars[min(int((v - min_val) / range_val * 7), 7)] for v in values)

# Uso: ascii_sparkline([1, 3, 7, 5, 2, 8, 4]) → "▁▃▆▅▂█▃"

Ejemplo completo: MCP App de Health Check

Veamos un ejemplo de principio a fin — un MCP server que funciona como health checker de servicios:

TypeScript

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

const server = new McpServer({ name: "health-checker", version: "1.0.0" });

async function checkService(name: string, url: string) {
  const start = Date.now();
  try {
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), 5000);
    const response = await fetch(url, { method: "HEAD", signal: controller.signal });
    clearTimeout(timeout);
    const elapsed = Date.now() - start;
    return { name, url, status: elapsed > 2000 ? "slow" as const : "up" as const,
             responseTimeMs: elapsed, statusCode: response.status };
  } catch {
    return { name, url, status: "down" as const, responseTimeMs: Date.now() - start };
  }
}

const STATUS_EMOJI = { up: "✅", slow: "⚠️", down: "❌" } as const;

function latencyBar(ms: number, maxMs = 5000, width = 15): string {
  const filled = Math.min(Math.round((ms / maxMs) * width), width);
  return "█".repeat(filled) + "░".repeat(width - filled);
}

server.tool(
  "check_all_services",
  "Verifica el estado de servicios y muestra un dashboard de salud",
  {
    services: z.array(z.object({
      name: z.string().describe("Nombre del servicio"),
      url: z.string().url().describe("URL a verificar"),
    })).min(1).describe("Lista de servicios a verificar"),
  },
  async ({ services }) => {
    const results = await Promise.all(
      services.map(svc => checkService(svc.name, svc.url))
    );

    const up = results.filter(r => r.status === "up").length;
    const slow = results.filter(r => r.status === "slow").length;
    const down = results.filter(r => r.status === "down").length;

    let output = `## 🏥 Service Health Dashboard

**Services:** ${results.length} total | ✅ ${up} up | ⚠️ ${slow} slow | ❌ ${down} down

| Service | Status | Latency | Code | Bar |
|---------|--------|---------|------|-----|
`;
    for (const svc of results) {
      output += `| ${svc.name} | ${STATUS_EMOJI[svc.status]} ${svc.status} | ${svc.responseTimeMs}ms | ${svc.statusCode || "N/A"} | ${latencyBar(svc.responseTimeMs)} |\n`;
    }

    const downSvcs = results.filter(r => r.status === "down");
    if (downSvcs.length > 0) {
      output += `\n### ❌ Servicios caídos\n\n`;
      for (const svc of downSvcs) output += `- **${svc.name}** (${svc.url})\n`;
    }

    return { content: [{ type: "text" as const, text: output }] };
  }
);

const transport = new StdioServerTransport();
await server.connect(transport);

Python equivalente (resumen)

La misma lógica en Python con FastMCP — el formateo es idéntico, solo cambia la sintaxis:

from mcp.server.fastmcp import FastMCP
import httpx, asyncio, time
from datetime import datetime

mcp = FastMCP("health-checker")

STATUS_EMOJI = {"up": "✅", "slow": "⚠️", "down": "❌"}


async def check_service(name: str, url: str) -> dict:
    start = time.time()
    try:
        async with httpx.AsyncClient(timeout=5.0) as client:
            response = await client.head(url)
            elapsed = int((time.time() - start) * 1000)
            return {"name": name, "url": url, "status": "slow" if elapsed > 2000 else "up",
                    "response_time_ms": elapsed, "status_code": response.status_code}
    except Exception:
        return {"name": name, "url": url, "status": "down",
                "response_time_ms": int((time.time() - start) * 1000), "status_code": None}


@mcp.tool()
async def check_all_services(urls: list[str], names: list[str] | None = None) -> str:
    """Verifica el estado de una lista de URLs y muestra un dashboard de salud."""
    service_names = names or [f"Service {i+1}" for i in range(len(urls))]
    results = await asyncio.gather(*[check_service(n, u) for n, u in zip(service_names, urls)])

    up = sum(1 for r in results if r["status"] == "up")
    slow = sum(1 for r in results if r["status"] == "slow")
    down = sum(1 for r in results if r["status"] == "down")

    lines = [
        f"## 🏥 Service Health Dashboard\n",
        f"**Services:** {len(results)} total | ✅ {up} up | ⚠️ {slow} slow | ❌ {down} down\n",
        "| Service | Status | Latency | Code |",
        "|---------|--------|---------|------|",
    ]
    for r in results:
        emoji = STATUS_EMOJI.get(r["status"], "❓")
        lines.append(f"| {r['name']} | {emoji} {r['status']} | {r['response_time_ms']}ms | {r['status_code'] or 'N/A'} |")

    return "\n".join(lines)

if __name__ == "__main__":
    mcp.run()

Lo que este ejemplo demuestra

  • Formateo de dashboard completo — header con resumen, tabla detallada, secciones condicionales
  • Indicadores visuales — emojis para status, barras ASCII para latencia
  • Secciones condicionales — solo muestra "Servicios caídos" si hay alguno caído
  • Información actionable — no solo datos, sino qué hacer con ellos
  • Ambos lenguajes — mismo output, implementación idiomática en cada uno

Cuándo formatear y cuándo no

Formatea cuando hay datos tabulares, métricas numéricas, o alertas que necesitan resaltarse. No formatees cuando el resultado es un valor simple, cuando el output será procesado por otro tool (JSON es mejor), o cuando estás debugging (JSON crudo es más útil para inspección).


Ejercicios

Ejercicio 1: Helper de formateo (Fácil)

Implementa una función format_file_tree que reciba una estructura de directorios como array de objetos { name, type, size?, children? } y la formatee como un tree visual.

Ver solución
interface FileNode {
  name: string;
  type: "file" | "directory";
  size?: number;
  children?: FileNode[];
}

function formatFileTree(nodes: FileNode[], prefix: string = "", isLast: boolean = true): string {
  let result = "";

  for (let i = 0; i < nodes.length; i++) {
    const node = nodes[i];
    const isLastNode = i === nodes.length - 1;
    const connector = isLastNode ? "└── " : "├── ";
    const icon = node.type === "directory" ? "📁" : "📄";
    const size = node.size ? ` (${(node.size / 1024).toFixed(1)} KB)` : "";

    result += `${prefix}${connector}${icon} ${node.name}${size}\n`;

    if (node.children && node.children.length > 0) {
      const childPrefix = prefix + (isLastNode ? "    " : "│   ");
      result += formatFileTree(node.children, childPrefix, isLastNode);
    }
  }

  return result;
}

// Output ejemplo:
// ├── 📁 src
// │   ├── 📄 index.ts (2.3 KB)
// │   ├── 📁 tools
// │   │   ├── 📄 search.ts (1.8 KB)
// │   │   └── 📄 files.ts (3.1 KB)
// │   └── 📄 utils.ts (0.9 KB)
// └── 📄 package.json (0.5 KB)

Ejercicio 2: Dashboard de métricas (Medio)

Crea un tool show_metrics que reciba un array de métricas { name, current, previous, unit } y retorne un dashboard con tabla, trend arrows, y barras de progreso.

Ver solución
from mcp.server.fastmcp import FastMCP
from pydantic import BaseModel, Field
import json

mcp = FastMCP("metrics-dashboard")


class Metric(BaseModel):
    name: str = Field(description="Nombre de la métrica")
    current: float = Field(description="Valor actual")
    previous: float = Field(description="Valor anterior (para calcular tendencia)")
    unit: str = Field(default="", description="Unidad (%, ms, count, etc.)")


def trend_arrow(current: float, previous: float) -> str:
    if previous == 0:
        return "→ N/A"
    change = ((current - previous) / previous) * 100
    if change > 5:
        return f"↑ +{change:.1f}%"
    elif change < -5:
        return f"↓ {change:.1f}%"
    return f"→ {change:+.1f}%"


def mini_bar(value: float, max_value: float, width: int = 10) -> str:
    filled = min(int(value / max_value * width), width) if max_value > 0 else 0
    return "█" * filled + "░" * (width - filled)


@mcp.tool()
async def show_metrics(metrics: list[Metric]) -> str:
    """Muestra un dashboard de métricas con tendencias y barras de progreso."""
    max_val = max(m.current for m in metrics) if metrics else 1

    lines = [
        "## 📈 Metrics Dashboard",
        "",
        f"**{len(metrics)} métricas** | Actualizado: ahora",
        "",
        "| Métrica | Actual | Anterior | Tendencia | Visual |",
        "|---------|--------|----------|-----------|--------|",
    ]

    for m in metrics:
        trend = trend_arrow(m.current, m.previous)
        bar = mini_bar(m.current, max_val)
        lines.append(
            f"| {m.name} | {m.current:.1f}{m.unit} | {m.previous:.1f}{m.unit} | {trend} | {bar} |"
        )

    improving = [m for m in metrics if m.current > m.previous * 1.05]
    declining = [m for m in metrics if m.current < m.previous * 0.95]

    if improving:
        lines.extend(["", "### 📈 Mejorando"])
        for m in improving:
            lines.append(f"- **{m.name}**: {m.previous:.1f} → {m.current:.1f}{m.unit}")

    if declining:
        lines.extend(["", "### 📉 Declinando"])
        for m in declining:
            lines.append(f"- **{m.name}**: {m.previous:.1f} → {m.current:.1f}{m.unit}")

    return "\n".join(lines)


if __name__ == "__main__":
    mcp.run()

Ejercicio 3: Formato condicional (Medio)

Implementa un tool format_log_entries (TypeScript) que reciba un array de log entries { timestamp, severity, message, source? } donde severity es "debug" | "info" | "warn" | "error" | "fatal". Agrupa las entries por severity (de más severa a menos), usa emojis para cada nivel, y muestra un resumen de conteo arriba.

Ver solución
const SEVERITY = {
  fatal: { emoji: "💀", label: "FATAL", order: 0 },
  error: { emoji: "❌", label: "ERROR", order: 1 },
  warn:  { emoji: "⚠️", label: "WARN",  order: 2 },
  info:  { emoji: "ℹ️", label: "INFO",  order: 3 },
  debug: { emoji: "🔍", label: "DEBUG", order: 4 },
};

server.tool(
  "format_log_entries",
  "Formatea log entries agrupadas por severity",
  {
    entries: z.array(z.object({
      timestamp: z.string(), severity: z.enum(["debug","info","warn","error","fatal"]),
      message: z.string(), source: z.string().optional(),
    })).min(1),
  },
  async ({ entries }) => {
    const grouped = new Map<string, typeof entries>();
    for (const e of entries) {
      if (!grouped.has(e.severity)) grouped.set(e.severity, []);
      grouped.get(e.severity)!.push(e);
    }
    const keys = [...grouped.keys()].sort((a, b) => SEVERITY[a].order - SEVERITY[b].order);

    let out = `## 📋 Log Viewer\n\n**${entries.length} entries:** `;
    out += keys.map(k => `${SEVERITY[k].emoji} ${grouped.get(k)!.length}`).join(" | ");
    out += "\n\n---\n\n";
    for (const sev of keys) {
      const { emoji, label } = SEVERITY[sev];
      out += `### ${emoji} ${label} (${grouped.get(sev)!.length})\n\n`;
      for (const e of grouped.get(sev)!) {
        const time = e.timestamp.split("T")[1]?.split(".")[0] || e.timestamp;
        out += `- \`${time}\`${e.source ? ` [${e.source}]` : ""} ${e.message}\n`;
      }
      out += "\n";
    }
    return { content: [{ type: "text" as const, text: out }] };
  }
);

Ejercicio 4: Multi-section report (Difícil)

Crea un tool generate_project_report que analice un directorio y retorne un reporte multi-sección con: file count by extension (con barras ASCII), top 5 archivos más grandes, y top 5 modificados recientemente. Usa os.walk para recorrer el directorio y excluye node_modules, .git, y __pycache__.

Ver solución
import os
from datetime import datetime, timedelta
from collections import defaultdict
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("project-reporter")


def size_str(b: int) -> str:
    if b < 1024: return f"{b} B"
    if b < 1024 * 1024: return f"{b/1024:.1f} KB"
    return f"{b/1024/1024:.1f} MB"


def time_ago(ts: float) -> str:
    delta = datetime.now() - datetime.fromtimestamp(ts)
    if delta < timedelta(hours=1): return f"hace {delta.seconds // 60}min"
    if delta < timedelta(days=1): return f"hace {delta.seconds // 3600}h"
    return f"hace {delta.days}d"


@mcp.tool()
async def generate_project_report(directory: str) -> str:
    """Genera un reporte multi-sección de un directorio de proyecto."""
    if not os.path.isdir(directory):
        return f"Error: '{directory}' no es un directorio válido"

    skip = {"node_modules", ".git", "__pycache__", ".venv", "venv"}
    files_by_ext: dict[str, int] = defaultdict(int)
    all_files: list[dict] = []
    total_size = 0

    for root, dirs, files in os.walk(directory):
        dirs[:] = [d for d in dirs if d not in skip]
        for f in files:
            filepath = os.path.join(root, f)
            try:
                stat = os.stat(filepath)
            except OSError:
                continue
            ext = os.path.splitext(f)[1] or "(none)"
            files_by_ext[ext] += 1
            total_size += stat.st_size
            all_files.append({"path": os.path.relpath(filepath, directory),
                              "size": stat.st_size, "modified": stat.st_mtime})

    largest = sorted(all_files, key=lambda f: -f["size"])[:5]
    recent = sorted(all_files, key=lambda f: -f["modified"])[:5]
    sorted_exts = sorted(files_by_ext.items(), key=lambda x: -x[1])[:8]
    max_count = max(files_by_ext.values()) if files_by_ext else 1

    report = f"## 📊 Reporte de Proyecto\n\n"
    report += f"**Directorio:** `{directory}`\n"
    report += f"**Total:** {len(all_files)} archivos | {size_str(total_size)}\n\n---\n\n"
    report += "### 📁 Por extensión\n\n| Ext | Cant | Distribución |\n|-----|------|-------------|\n"
    for ext, count in sorted_exts:
        bar = "█" * int(count / max_count * 12) + "░" * (12 - int(count / max_count * 12))
        report += f"| `{ext}` | {count} | {bar} |\n"
    report += "\n---\n\n### 📏 Más grandes\n\n| Archivo | Tamaño |\n|---------|--------|\n"
    for f in largest:
        report += f"| `{f['path']}` | {size_str(f['size'])} |\n"
    report += "\n---\n\n### 🕐 Recientes\n\n| Archivo | Modificado |\n|---------|----------|\n"
    for f in recent:
        report += f"| `{f['path']}` | {time_ago(f['modified'])} |\n"
    return report

if __name__ == "__main__":
    mcp.run()

Troubleshooting

"El markdown no se renderiza correctamente"

Causa: El texto tiene problemas de formato — pipes desalineados en tablas, falta el separador de header, o hay caracteres especiales.

Solución:

<!-- ❌ Tabla rota — falta separador -->
| Header1 | Header2 |
| data1 | data2 |

<!-- ✅ Tabla correcta -->
| Header1 | Header2 |
|---------|---------|
| data1 | data2 |

Asegúrate de que cada tabla tenga exactamente el mismo número de | en cada fila, incluyendo el separador.

"Las barras ASCII se ven desalineadas"

Causa: Caracteres Unicode de diferente ancho (emojis, caracteres CJK) desalinean las columnas.

Solución: Usa caracteres de ancho fijo para las barras (█, ░, ▓, ▒) y evita mezclar emojis dentro de secciones que necesitan alineamiento preciso. Los emojis van mejor en headers y labels.

"El output es demasiado largo y se trunca"

Causa: Estás retornando demasiados datos. Claude Code tiene límites en la longitud de la respuesta.

Solución:

# Limita la cantidad de items
results = sorted(data, key=lambda x: -x["value"])[:MAX_ITEMS]

# Agrega un footer indicando truncamiento
if len(data) > MAX_ITEMS:
    output += f"\n*Mostrando top {MAX_ITEMS} de {len(data)} total.*"

Resumen

En esta cápsula aprendiste:

  • La anatomía de un tool response — content types, múltiples bloques, type: "text" como formato principal
  • El proceso de 3 pasos — obtener datos → procesar/agregar → formatear como output rico
  • 5 patrones de formateo — tablas markdown, indicadores con emojis, secciones con headers, ASCII charts, key-value pairs
  • Un ejemplo completo — health checker con dashboard en ambos lenguajes
  • Cuándo formatear y cuándo no — formateo rico para datos complejos/recurrentes, texto plano para valores simples y debugging

El formateo del output no es cosmético — es lo que convierte un MCP server funcional en uno que usas todos los días.


Recursos adicionales

  1. MCP Specification — Content Types — Tipos de contenido en respuestas MCP
  2. Markdown Guide — Tables — Referencia de tablas markdown
  3. Unicode Block Characters — Caracteres para barras y gráficos
  4. MCP TypeScript SDK — SDK oficial
  5. MCP Python SDK — SDK oficial
  6. ASCII Art — Bar Charts — Técnicas para charts en terminal

Siguiente cápsula: Dashboards y Visualizaciones — construir dashboards completos con múltiples secciones, charts ASCII avanzados, y reportes de datos que Claude Code presenta de forma visual.