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 áreas
  • file_analysis — Drill-down: distribución de archivos, tamaños, complejidad
  • git_analysis — Drill-down: actividad de git, contributors, frecuencia de commits
  • deps_analysis — Drill-down: dependencias, versiones, posibles issues
  • configure_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:

  1. Verifica que aparezcan los 5 tools: project_overview, file_analysis, git_analysis, deps_analysis, configure_dashboard
  2. Verifica que aparezca el resource: analytics://config
  3. Ejecuta project_overview con directory apuntando a un proyecto real
  4. Verifica que el dashboard tenga las secciones esperadas
  5. Ejecuta los drill-downs para verificar que funcionan
  6. Ejecuta configure_dashboard sin parámetros para ver la config actual
  7. Cambia la config y verifica que project_overview refleje 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 (o python server.py sin crash)
  • MCP Inspector muestra los 5 tools y el resource
  • project_overview retorna un dashboard formateado con secciones
  • file_analysis retorna un drill-down detallado de archivos
  • git_analysis retorna información de git (o mensaje apropiado si no es un repo)
  • deps_analysis retorna dependencias (o mensaje si no hay package.json)
  • configure_dashboard sin 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_overview y 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/gitDashboard de datos de tu database/API
Drill-down por áreaDrill-down por entidad/tabla
Configuración in-memoryConfiguración persistente
Sin testsTest suite completo (módulo 7)
Output formateado estáticoOutput 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:

  1. Cápsula 01: Qué son las MCP Apps, sus capacidades y limitaciones, y cuándo usarlas vs web apps
  2. Cápsula 02: Anatomía de un tool response, patrones de formateo (tablas, charts, indicadores), helpers reutilizables
  3. Cápsula 03: Dashboards completos (project status, DB analytics, system health), patrón drill-down
  4. Cápsula 04: Interactividad (preview/execute, workflows multi-paso, prompts como orchestradores, captura de datos)
  5. 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

  1. MCP TypeScript SDK — SDK oficial
  2. MCP Python SDK — SDK oficial
  3. MCP Inspector — Debugging visual
  4. Node.js child_process — Para ejecutar comandos git
  5. MCP Specification — Especificación del protocolo
  6. ASCII Charts Inspiration — Técnicas para visualización en terminal
  7. Markdown Guide — Referencia de formato markdown
  8. Claude Code MCP Configuration — Configurar MCP en Claude Code