Módulo 6: MCP Apps y UI Interactivo
Proyecto: MCP App con Dashboard Interactivo
Proyecto: MCP App con Dashboard Interactivo
Descripción de la cápsula
Llegó el momento de unir todo. En las cápsulas anteriores aprendiste qué son las MCP Apps, cómo construir dashboards con output visual, y cómo diseñar workflows interactivos. Ahora vas a construir un Project Analytics MCP App — un MCP server completo que analiza tu proyecto de desarrollo y presenta la información como un dashboard interactivo con capacidad de drill-down.
Este no es un ejemplo artificial. Al terminar, tendrás una herramienta que puedes usar diariamente con Claude Code para monitorear cualquier proyecto: distribución de código, actividad de git, complejidad de archivos, dependencias, y más. Le dices a Claude Code "muéstrame el estado de mi proyecto" y ves un dashboard formateado con métricas actionables.
Lo que vas a construir
Project Analytics MCP App
Un MCP server con dashboard multi-sección, drill-down por área, y configuración interactiva:
project-analytics/
├── src/
│ ├── server.ts # Entry point y registro de tools
│ ├── analyzers/
│ │ ├── files.ts # Análisis de archivos y código
│ │ ├── git.ts # Análisis de git history
│ │ └── deps.ts # Análisis de dependencias
│ ├── formatters/
│ │ ├── dashboard.ts # Formateo del dashboard principal
│ │ ├── charts.ts # Helpers de ASCII charts
│ │ └── tables.ts # Helpers de tablas markdown
│ └── types.ts # Tipos compartidos
├── package.json
└── tsconfig.json
Capabilities
Tools:
project_overview— Dashboard principal con resumen de todas las áreasfile_analysis— Drill-down: distribución de archivos, tamaños, complejidadgit_analysis— Drill-down: actividad de git, contributors, frecuencia de commitsdeps_analysis— Drill-down: dependencias, versiones, posibles issuesconfigure_dashboard— Configurar qué secciones mostrar en el overview
Resources:
analytics://config— Configuración actual del dashboard
Paso 1: Setup del proyecto
Crear la estructura
mkdir project-analytics
cd project-analytics
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node
mkdir -p src/analyzers src/formatters
package.json
{
"name": "project-analytics",
"version": "1.0.0",
"type": "module",
"main": "build/server.js",
"scripts": {
"build": "tsc",
"start": "node build/server.js",
"dev": "tsc --watch",
"inspect": "npm run build && npx @modelcontextprotocol/inspector node build/server.js"
}
}
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"sourceMap": true
},
"include": ["src/**/*"]
}
Paso 2: Tipos compartidos
src/types.ts
export interface FileInfo {
path: string;
extension: string;
size: number;
lines: number;
modified: number;
}
export interface GitCommit {
hash: string;
message: string;
author: string;
date: string;
filesChanged: number;
}
export interface DepsInfo {
name: string;
version: string;
type: "production" | "dev";
}
export interface DashboardConfig {
showFiles: boolean;
showGit: boolean;
showDeps: boolean;
topN: number;
targetDir: string;
}
export const DEFAULT_CONFIG: DashboardConfig = {
showFiles: true,
showGit: true,
showDeps: true,
topN: 8,
targetDir: ".",
};
Paso 3: Helpers de formateo
src/formatters/charts.ts
export function progressBar(value: number, max: number, width = 15): string {
if (max === 0) return "░".repeat(width) + " 0%";
const pct = Math.min(value / max, 1);
const filled = Math.round(pct * width);
return "█".repeat(filled) + "░".repeat(width - filled) + ` ${(pct * 100).toFixed(0)}%`;
}
export function statusIndicator(value: number, low: number, high: number): string {
if (value >= high) return "🟢";
if (value >= low) return "🟡";
return "🔴";
}
export function sparkline(values: number[]): string {
if (values.length === 0) return "";
const chars = "▁▂▃▄▅▆▇█";
const min = Math.min(...values);
const max = Math.max(...values);
const range = max - min || 1;
return values.map(v => chars[Math.min(Math.floor(((v - min) / range) * 7), 7)]).join("");
}
export 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`;
}
export function timeAgo(timestamp: number): string {
const diff = Date.now() - timestamp;
const mins = Math.floor(diff / 60000);
if (mins < 60) return `${mins}min ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
}
src/formatters/tables.ts
export function markdownTable(
headers: string[],
rows: string[][],
): string {
const headerRow = `| ${headers.join(" | ")} |`;
const separator = `| ${headers.map(() => "---").join(" | ")} |`;
const dataRows = rows.map(row => `| ${row.join(" | ")} |`).join("\n");
return `${headerRow}\n${separator}\n${dataRows}`;
}
export function section(title: string, content: string, emoji = "📋"): string {
return `\n### ${emoji} ${title}\n\n${content}\n\n---`;
}
Paso 4: Analyzers
src/analyzers/files.ts
import * as fs from "fs/promises";
import * as path from "path";
import type { FileInfo } from "../types.js";
const SKIP_DIRS = new Set([
"node_modules", ".git", "__pycache__", ".venv", "venv",
"dist", "build", ".next", "coverage", ".cache",
]);
const TEXT_EXTENSIONS = new Set([
".ts", ".tsx", ".js", ".jsx", ".py", ".rs", ".go", ".java",
".css", ".scss", ".html", ".md", ".json", ".yaml", ".yml",
".toml", ".sql", ".sh", ".bash",
]);
export async function analyzeFiles(rootDir: string): Promise<FileInfo[]> {
const files: FileInfo[] = [];
async function walk(dir: string): Promise<void> {
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (SKIP_DIRS.has(entry.name)) continue;
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
await walk(fullPath);
continue;
}
try {
const stats = await fs.stat(fullPath);
const ext = path.extname(entry.name);
let lines = 0;
if (TEXT_EXTENSIONS.has(ext) && stats.size < 512 * 1024) {
try {
const content = await fs.readFile(fullPath, "utf-8");
lines = content.split("\n").length;
} catch {
// binary or unreadable
}
}
files.push({
path: path.relative(rootDir, fullPath),
extension: ext || "(none)",
size: stats.size,
lines,
modified: stats.mtimeMs,
});
} catch {
// skip inaccessible files
}
}
}
await walk(rootDir);
return files;
}
export function getFileStats(files: FileInfo[]) {
const byExtension: Record<string, { count: number; totalSize: number; totalLines: number }> = {};
for (const file of files) {
if (!byExtension[file.extension]) {
byExtension[file.extension] = { count: 0, totalSize: 0, totalLines: 0 };
}
byExtension[file.extension].count++;
byExtension[file.extension].totalSize += file.size;
byExtension[file.extension].totalLines += file.lines;
}
const totalSize = files.reduce((sum, f) => sum + f.size, 0);
const totalLines = files.reduce((sum, f) => sum + f.lines, 0);
const largest = [...files].sort((a, b) => b.size - a.size);
const mostRecent = [...files].sort((a, b) => b.modified - a.modified);
const mostLines = [...files].filter(f => f.lines > 0).sort((a, b) => b.lines - a.lines);
return { byExtension, totalSize, totalLines, largest, mostRecent, mostLines };
}
src/analyzers/git.ts
import { execSync } from "child_process";
import type { GitCommit } from "../types.js";
export function getGitInfo(dir: string): {
branch: string;
commits: GitCommit[];
status: string[];
contributorStats: { name: string; commits: number }[];
} | null {
try {
execSync("git rev-parse --is-inside-work-tree", { cwd: dir, stdio: "pipe" });
} catch {
return null;
}
const branch = run("git branch --show-current", dir) || "detached";
const logRaw = run('git log --pretty=format:"%h|%s|%an|%ar" -15', dir);
const commits: GitCommit[] = logRaw
? logRaw.split("\n").map(line => {
const [hash, message, author, date] = line.split("|");
return { hash, message, author, date, filesChanged: 0 };
})
: [];
const statusRaw = run("git status --short", dir);
const status = statusRaw ? statusRaw.split("\n").filter(Boolean) : [];
const contribRaw = run("git shortlog -sn --no-merges HEAD~50..HEAD 2>/dev/null || git shortlog -sn --no-merges -10", dir);
const contributorStats = contribRaw
? contribRaw.split("\n").filter(Boolean).map(line => {
const match = line.trim().match(/^(\d+)\s+(.+)$/);
return match ? { name: match[2], commits: parseInt(match[1]) } : null;
}).filter((c): c is { name: string; commits: number } => c !== null)
: [];
return { branch, commits, status, contributorStats };
}
function run(cmd: string, cwd: string): string {
try {
return execSync(cmd, { cwd, timeout: 5000, stdio: "pipe" }).toString().trim();
} catch {
return "";
}
}
src/analyzers/deps.ts
import * as fs from "fs/promises";
import * as path from "path";
import type { DepsInfo } from "../types.js";
export async function analyzeDeps(dir: string): Promise<{
deps: DepsInfo[];
packageName: string;
packageVersion: string;
} | null> {
const pkgPath = path.join(dir, "package.json");
try {
const raw = await fs.readFile(pkgPath, "utf-8");
const pkg = JSON.parse(raw);
const deps: DepsInfo[] = [];
for (const [name, version] of Object.entries(pkg.dependencies || {})) {
deps.push({ name, version: version as string, type: "production" });
}
for (const [name, version] of Object.entries(pkg.devDependencies || {})) {
deps.push({ name, version: version as string, type: "dev" });
}
return {
deps,
packageName: pkg.name || "unknown",
packageVersion: pkg.version || "0.0.0",
};
} catch {
return null;
}
}
Paso 5: Server principal
src/server.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { analyzeFiles, getFileStats } from "./analyzers/files.js";
import { getGitInfo } from "./analyzers/git.js";
import { analyzeDeps } from "./analyzers/deps.js";
import { progressBar, sizeStr, timeAgo, sparkline, statusIndicator } from "./formatters/charts.js";
import { markdownTable, section } from "./formatters/tables.js";
import type { DashboardConfig } from "./types.js";
import { DEFAULT_CONFIG } from "./types.js";
const server = new McpServer({
name: "project-analytics",
version: "1.0.0",
});
let config: DashboardConfig = { ...DEFAULT_CONFIG };
// ── Resource: configuración actual ──
server.resource(
"config",
"analytics://config",
{ description: "Configuración actual del dashboard" },
async (uri) => ({
contents: [{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify(config, null, 2),
}],
})
);
// ── Tool: Dashboard principal ──
server.tool(
"project_overview",
"Dashboard principal del proyecto con resumen de archivos, git, y dependencias. Muestra métricas clave y permite drill-down por área.",
{
directory: z.string().default(".").describe("Directorio del proyecto a analizar"),
},
async ({ directory }) => {
const targetDir = directory || config.targetDir;
const files = await analyzeFiles(targetDir);
if (files.length === 0) {
return {
content: [{ type: "text" as const, text: `## 📊 Project Analytics\n\n⚠️ No se encontraron archivos en \`${targetDir}\`` }],
};
}
const stats = getFileStats(files);
const git = getGitInfo(targetDir);
const deps = await analyzeDeps(targetDir);
let dashboard = `## 📊 Project Analytics Dashboard\n\n`;
dashboard += `**Directorio:** \`${targetDir}\`\n`;
dashboard += `**Timestamp:** ${new Date().toLocaleString()}\n\n`;
// KPIs
const kpis = [
`📁 ${files.length} archivos`,
`📏 ${stats.totalLines.toLocaleString()} líneas`,
`💾 ${sizeStr(stats.totalSize)}`,
];
if (git) kpis.push(`🔀 branch: ${git.branch}`);
if (deps) kpis.push(`📦 ${deps.deps.length} deps`);
dashboard += `**${kpis.join(" | ")}**\n\n---`;
// Files section
if (config.showFiles) {
const sortedExts = Object.entries(stats.byExtension)
.sort(([, a], [, b]) => b.count - a.count)
.slice(0, config.topN);
const maxCount = sortedExts[0]?.[1].count || 1;
const fileRows = sortedExts.map(([ext, data]) => [
`\`${ext}\``,
`${data.count}`,
`${data.totalLines.toLocaleString()}`,
progressBar(data.count, maxCount, 12),
]);
const fileTable = markdownTable(["Ext", "Files", "Lines", "Distribution"], fileRows);
dashboard += section("Archivos por tipo", fileTable, "📁");
}
// Git section
if (config.showGit && git) {
let gitContent = "";
if (git.status.length > 0) {
gitContent += `**Working tree:** ⚠️ ${git.status.length} cambios pendientes\n\n`;
} else {
gitContent += `**Working tree:** ✅ Limpio\n\n`;
}
if (git.commits.length > 0) {
const commitRows = git.commits.slice(0, 5).map(c => [
`\`${c.hash}\``,
c.message.length > 50 ? c.message.substring(0, 47) + "..." : c.message,
c.author,
c.date,
]);
gitContent += markdownTable(["Hash", "Message", "Author", "When"], commitRows);
}
if (git.contributorStats.length > 0) {
const maxCommits = git.contributorStats[0].commits;
gitContent += "\n\n**Contributors (recent):**\n\n";
for (const c of git.contributorStats.slice(0, 5)) {
gitContent += `- ${c.name}: ${progressBar(c.commits, maxCommits, 10)} (${c.commits})\n`;
}
}
dashboard += section("Git", gitContent, "🔀");
}
// Deps section
if (config.showDeps && deps) {
const prodDeps = deps.deps.filter(d => d.type === "production");
const devDeps = deps.deps.filter(d => d.type === "dev");
let depsContent = `**${deps.packageName}@${deps.packageVersion}**\n`;
depsContent += `Production: ${prodDeps.length} | Dev: ${devDeps.length}\n\n`;
if (prodDeps.length > 0) {
const depRows = prodDeps.slice(0, config.topN).map(d => [
`\`${d.name}\``, d.version,
]);
depsContent += markdownTable(["Package", "Version"], depRows);
}
dashboard += section("Dependencias", depsContent, "📦");
}
// Footer with drill-down
dashboard += `\n### 🔍 Drill-down\n\n`;
dashboard += `- \`file_analysis\` — Detalle de archivos (largest, most lines, recent)\n`;
dashboard += `- \`git_analysis\` — Detalle de git (all commits, full contributor stats)\n`;
dashboard += `- \`deps_analysis\` — Detalle de dependencias (all deps, versions)\n`;
dashboard += `- \`configure_dashboard\` — Configurar qué secciones mostrar\n`;
return { content: [{ type: "text" as const, text: dashboard }] };
}
);
// ── Tool: File drill-down ──
server.tool(
"file_analysis",
"Análisis detallado de archivos del proyecto: distribución, archivos más grandes, más líneas, y más recientes",
{
directory: z.string().default(".").describe("Directorio a analizar"),
topN: z.number().int().positive().default(10).describe("Top N items por sección"),
},
async ({ directory, topN }) => {
const files = await analyzeFiles(directory);
const stats = getFileStats(files);
let output = `## 📁 File Analysis — Detalle\n\n`;
output += `**Total:** ${files.length} archivos | ${stats.totalLines.toLocaleString()} líneas | ${sizeStr(stats.totalSize)}\n\n---\n\n`;
// Full extension breakdown
const sortedExts = Object.entries(stats.byExtension).sort(([, a], [, b]) => b.count - a.count);
const maxCount = sortedExts[0]?.[1].count || 1;
const extRows = sortedExts.map(([ext, data]) => [
`\`${ext}\``, `${data.count}`, `${data.totalLines.toLocaleString()}`,
sizeStr(data.totalSize), progressBar(data.count, maxCount, 10),
]);
output += `### Distribución completa\n\n`;
output += markdownTable(["Ext", "Files", "Lines", "Size", "Bar"], extRows);
// Largest files
output += `\n\n---\n\n### 📏 Top ${topN} archivos más grandes\n\n`;
const largestRows = stats.largest.slice(0, topN).map(f => [
`\`${f.path}\``, sizeStr(f.size), `${f.lines.toLocaleString()} lines`,
]);
output += markdownTable(["File", "Size", "Lines"], largestRows);
// Most lines
output += `\n\n---\n\n### 📝 Top ${topN} más líneas de código\n\n`;
const linesRows = stats.mostLines.slice(0, topN).map(f => [
`\`${f.path}\``, `${f.lines.toLocaleString()}`, sizeStr(f.size),
]);
output += markdownTable(["File", "Lines", "Size"], linesRows);
// Most recent
output += `\n\n---\n\n### 🕐 Modificados recientemente\n\n`;
const recentRows = stats.mostRecent.slice(0, topN).map(f => [
`\`${f.path}\``, timeAgo(f.modified),
]);
output += markdownTable(["File", "Modified"], recentRows);
output += `\n\n---\n\n*Vuelve al overview con \`project_overview\`*`;
return { content: [{ type: "text" as const, text: output }] };
}
);
// ── Tool: Git drill-down ──
server.tool(
"git_analysis",
"Análisis detallado de git: historial de commits, contributors, y estado del working tree",
{
directory: z.string().default(".").describe("Directorio del repositorio"),
},
async ({ directory }) => {
const git = getGitInfo(directory);
if (!git) {
return {
content: [{ type: "text" as const, text: `## 🔀 Git Analysis\n\n⚠️ \`${directory}\` no es un repositorio git` }],
};
}
let output = `## 🔀 Git Analysis — Detalle\n\n`;
output += `**Branch:** \`${git.branch}\`\n`;
output += `**Working tree:** ${git.status.length === 0 ? "✅ Limpio" : `⚠️ ${git.status.length} cambios`}\n\n---\n\n`;
// Commits
if (git.commits.length > 0) {
output += `### 📝 Últimos commits\n\n`;
const commitRows = git.commits.map(c => [
`\`${c.hash}\``, c.message, c.author, c.date,
]);
output += markdownTable(["Hash", "Message", "Author", "When"], commitRows);
}
// Contributors
if (git.contributorStats.length > 0) {
const maxCommits = git.contributorStats[0].commits;
output += `\n\n---\n\n### 👥 Contributors\n\n`;
const contribRows = git.contributorStats.map(c => [
c.name, `${c.commits}`, progressBar(c.commits, maxCommits, 12),
]);
output += markdownTable(["Name", "Commits", "Activity"], contribRows);
}
// Working tree changes
if (git.status.length > 0) {
output += `\n\n---\n\n### 📋 Cambios pendientes\n\n`;
for (const line of git.status.slice(0, 15)) {
output += `- \`${line}\`\n`;
}
if (git.status.length > 15) {
output += `\n*...y ${git.status.length - 15} más*\n`;
}
}
output += `\n\n---\n\n*Vuelve al overview con \`project_overview\`*`;
return { content: [{ type: "text" as const, text: output }] };
}
);
// ── Tool: Deps drill-down ──
server.tool(
"deps_analysis",
"Análisis detallado de dependencias del proyecto",
{
directory: z.string().default(".").describe("Directorio del proyecto"),
},
async ({ directory }) => {
const deps = await analyzeDeps(directory);
if (!deps) {
return {
content: [{ type: "text" as const, text: `## 📦 Deps Analysis\n\n⚠️ No se encontró \`package.json\` en \`${directory}\`` }],
};
}
const prod = deps.deps.filter(d => d.type === "production");
const dev = deps.deps.filter(d => d.type === "dev");
let output = `## 📦 Dependencies Analysis\n\n`;
output += `**Package:** ${deps.packageName}@${deps.packageVersion}\n`;
output += `**Total:** ${deps.deps.length} | Production: ${prod.length} | Dev: ${dev.length}\n\n---\n\n`;
if (prod.length > 0) {
output += `### Production Dependencies\n\n`;
const prodRows = prod.map(d => [`\`${d.name}\``, d.version]);
output += markdownTable(["Package", "Version"], prodRows);
}
if (dev.length > 0) {
output += `\n\n---\n\n### Dev Dependencies\n\n`;
const devRows = dev.map(d => [`\`${d.name}\``, d.version]);
output += markdownTable(["Package", "Version"], devRows);
}
output += `\n\n---\n\n*Vuelve al overview con \`project_overview\`*`;
return { content: [{ type: "text" as const, text: output }] };
}
);
// ── Tool: Configurar dashboard ──
server.tool(
"configure_dashboard",
"Configura qué secciones mostrar en el dashboard overview. Sin parámetros muestra la config actual.",
{
showFiles: z.boolean().optional().describe("Mostrar sección de archivos"),
showGit: z.boolean().optional().describe("Mostrar sección de git"),
showDeps: z.boolean().optional().describe("Mostrar sección de dependencias"),
topN: z.number().int().min(3).max(20).optional().describe("Items por sección (3-20)"),
},
async (params) => {
const hasChanges = Object.values(params).some(v => v !== undefined);
if (!hasChanges) {
return {
content: [{
type: "text" as const,
text: `## ⚙️ Dashboard Config\n\n| Setting | Value |\n|---------|-------|\n| Show Files | ${config.showFiles ? "✅" : "❌"} |\n| Show Git | ${config.showGit ? "✅" : "❌"} |\n| Show Deps | ${config.showDeps ? "✅" : "❌"} |\n| Top N | ${config.topN} |\n\nPasa parámetros para cambiar la configuración.`,
}],
};
}
if (params.showFiles !== undefined) config.showFiles = params.showFiles;
if (params.showGit !== undefined) config.showGit = params.showGit;
if (params.showDeps !== undefined) config.showDeps = params.showDeps;
if (params.topN !== undefined) config.topN = params.topN;
return {
content: [{
type: "text" as const,
text: `## ✅ Config Actualizada\n\n| Setting | Value |\n|---------|-------|\n| Show Files | ${config.showFiles ? "✅" : "❌"} |\n| Show Git | ${config.showGit ? "✅" : "❌"} |\n| Show Deps | ${config.showDeps ? "✅" : "❌"} |\n| Top N | ${config.topN} |\n\nUsa \`project_overview\` para ver el dashboard con la nueva configuración.`,
}],
};
}
);
// ── Start server ──
const transport = new StdioServerTransport();
await server.connect(transport);
Paso 6: Build y test con MCP Inspector
Compilar
npm run build
Si hay errores de compilación, revisa que los imports usen extensión .js (requerido para ESM con Node16 module resolution).
Probar con MCP Inspector
npm run inspect
En MCP Inspector:
- Verifica que aparezcan los 5 tools:
project_overview,file_analysis,git_analysis,deps_analysis,configure_dashboard - Verifica que aparezca el resource:
analytics://config - Ejecuta
project_overviewcondirectoryapuntando a un proyecto real - Verifica que el dashboard tenga las secciones esperadas
- Ejecuta los drill-downs para verificar que funcionan
- Ejecuta
configure_dashboardsin parámetros para ver la config actual - Cambia la config y verifica que
project_overviewrefleje los cambios
Paso 7: Conectar a Claude Code
Agregar el server
cd /ruta/a/project-analytics
npm run build
claude mcp add project-analytics node /ruta/a/project-analytics/build/server.js
Verificar la conexión
claude mcp list
Deberías ver project-analytics en la lista con status connected.
Usar el dashboard
Abre Claude Code y prueba:
Muéstrame el estado de mi proyecto en /ruta/a/mi-proyecto
Claude Code debería invocar project_overview y mostrarte el dashboard formateado.
Prueba el drill-down:
Dame más detalle sobre los archivos del proyecto
Claude Code invocará file_analysis y te mostrará el análisis detallado.
Prueba la configuración:
Configura el dashboard para que no muestre dependencias y muestre 15 items por sección
Claude Code invocará configure_dashboard con los parámetros apropiados.
Versión Python (alternativa)
Si prefieres implementar el proyecto en Python, la estructura es equivalente. Los mismos patrones aplican — FastMCP, decoradores @mcp.tool(), y funciones helper para formateo. La diferencia principal es que Python usa os.walk() en lugar de una función walk recursiva, y subprocess.run() en lugar de execSync para comandos git.
La estructura de archivos sería:
project-analytics-py/
├── server.py # Todo en un archivo (o separado en módulos)
├── requirements.txt # mcp[cli]
└── .venv/
Para conectar:
pip install "mcp[cli]"
claude mcp add project-analytics python /ruta/a/server.py
Los tools y sus parámetros son idénticos — project_overview, file_analysis, git_analysis, deps_analysis, configure_dashboard. Lo que cambia es la sintaxis, no el diseño. Revisa las cápsulas 02-04 para ver los ejemplos Python de cada patrón.
Checklist final del proyecto
Antes de considerar el proyecto completo, verifica:
- El server compila sin errores:
npm run build(opython server.pysin crash) - MCP Inspector muestra los 5 tools y el resource
-
project_overviewretorna un dashboard formateado con secciones -
file_analysisretorna un drill-down detallado de archivos -
git_analysisretorna información de git (o mensaje apropiado si no es un repo) -
deps_analysisretorna dependencias (o mensaje si no hay package.json) -
configure_dashboardsin params muestra config, con params la actualiza - El dashboard refleja cambios de configuración
- El server está conectado a Claude Code via
claude mcp add - Claude Code puede invocar
project_overviewy muestra el dashboard - Los drill-downs funcionan cuando se piden más detalles
- Los datos son reales — analizaste un proyecto real, no datos de ejemplo
Conexión con el Módulo 8
Este proyecto demuestra el patrón de MCP App con dashboard interactivo. En el proyecto integrador del módulo 8, puedes incorporar estos patrones:
| Este módulo (M6) | Proyecto integrador (M8) |
|---|---|
| Dashboard de archivos/git | Dashboard de datos de tu database/API |
| Drill-down por área | Drill-down por entidad/tabla |
| Configuración in-memory | Configuración persistente |
| Sin tests | Test suite completo (módulo 7) |
| Output formateado estático | Output con datos en tiempo real |
Los patrones de formateo — tablas, barras, secciones, drill-down — se aplican directamente. Lo que cambia es la fuente de datos.
Troubleshooting
"El dashboard está vacío"
Causa: El directorio no tiene archivos o está excluido por SKIP_DIRS.
Solución: Verifica que directory apunta a la ruta correcta y que no es un directorio que esté en la lista de exclusión.
"Git analysis retorna null"
Causa: El directorio no es un repositorio git.
Solución: Asegúrate de que el directorio (o un padre) sea un repo git inicializado.
"El formateo se rompe con archivos con caracteres especiales"
Causa: Nombres de archivo con | o backticks rompen las tablas markdown.
Solución:
const safeName = name.replace(/\|/g, "\\|").replace(/`/g, "'");
"execSync timeout"
Causa: Repositorios muy grandes hacen que los comandos git tarden más de 5 segundos.
Solución: Aumenta el timeout o limita el rango de commits analizados.
Resumen del módulo
A lo largo de las 5 cápsulas de este módulo, aprendiste:
- Cápsula 01: Qué son las MCP Apps, sus capacidades y limitaciones, y cuándo usarlas vs web apps
- Cápsula 02: Anatomía de un tool response, patrones de formateo (tablas, charts, indicadores), helpers reutilizables
- Cápsula 03: Dashboards completos (project status, DB analytics, system health), patrón drill-down
- Cápsula 04: Interactividad (preview/execute, workflows multi-paso, prompts como orchestradores, captura de datos)
- Cápsula 05: Proyecto — MCP App con dashboard de analytics interactivo conectado a Claude Code
Lo que ahora puedes hacer
- ✅ Diseñar tools que retornan output visualmente rico y útil
- ✅ Construir dashboards con tablas, charts ASCII, y indicadores
- ✅ Implementar drill-down para navegación del resumen al detalle
- ✅ Crear workflows multi-paso con confirmaciones
- ✅ Combinar tools y prompts para flujos interactivos
- ✅ Saber cuándo usar MCP Apps y cuándo necesitas una web app
- ✅ Tener un MCP App funcional corriendo en Claude Code
Lo que viene
- Módulo 7: Testing, Debugging e Integración — testing automatizado de MCP servers, MCP Inspector avanzado, troubleshooting
- Módulo 8: Proyecto Integrador — MCP server production-ready con database real, donde puedes aplicar los patrones de dashboard de este módulo
Recursos adicionales
- MCP TypeScript SDK — SDK oficial
- MCP Python SDK — SDK oficial
- MCP Inspector — Debugging visual
- Node.js child_process — Para ejecutar comandos git
- MCP Specification — Especificación del protocolo
- ASCII Charts Inspiration — Técnicas para visualización en terminal
- Markdown Guide — Referencia de formato markdown
- Claude Code MCP Configuration — Configurar MCP en Claude Code