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
- MCP Specification — Content Types — Tipos de contenido en respuestas MCP
- Markdown Guide — Tables — Referencia de tablas markdown
- Unicode Block Characters — Caracteres para barras y gráficos
- MCP TypeScript SDK — SDK oficial
- MCP Python SDK — SDK oficial
- 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.