Module 6: MCP Apps and Interactive UI

Project: MCP App with an Interactive Dashboard

Project: MCP App with an Interactive Dashboard

Capsule description

The moment to bring everything together has arrived. In the previous capsules you learned what MCP Apps are, how to build dashboards with visual output, and how to design interactive workflows. Now you're going to build a Project Analytics MCP App — a complete MCP server that analyzes your development project and presents the information as an interactive dashboard with drill-down capability.

This isn't an artificial example. By the end, you'll have a tool you can use daily with Claude Code to monitor any project: code distribution, git activity, file complexity, dependencies, and more. You tell Claude Code "show me the status of my project" and you see a formatted dashboard with actionable metrics.


What you're going to build

Project Analytics MCP App

An MCP server with a multi-section dashboard, drill-down by area, and interactive configuration:

project-analytics/
├── src/
│   ├── server.ts          # Entry point and tool registration
│   ├── analyzers/
│   │   ├── files.ts        # File and code analysis
│   │   ├── git.ts          # Git history analysis
│   │   └── deps.ts         # Dependency analysis
│   ├── formatters/
│   │   ├── dashboard.ts    # Main dashboard formatting
│   │   ├── charts.ts       # ASCII chart helpers
│   │   └── tables.ts       # Markdown table helpers
│   └── types.ts            # Shared types
├── package.json
└── tsconfig.json

Capabilities

Tools:

  • project_overview — Main dashboard with a summary of all the areas
  • file_analysis — Drill-down: file distribution, sizes, complexity
  • git_analysis — Drill-down: git activity, contributors, commit frequency
  • deps_analysis — Drill-down: dependencies, versions, possible issues
  • configure_dashboard — Configure which sections to show in the overview

Resources:

  • analytics://config — Current dashboard configuration

Step 1: Project setup

Create the structure

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/**/*"]
}

Step 2: Shared types

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: ".",
};

Step 3: Formatting helpers

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---`;
}

Step 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;
  }
}

Step 5: Main server

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: current configuration ──

server.resource(
  "config",
  "analytics://config",
  { description: "Current dashboard configuration" },
  async (uri) => ({
    contents: [{
      uri: uri.href,
      mimeType: "application/json",
      text: JSON.stringify(config, null, 2),
    }],
  })
);

// ── Tool: Main dashboard ──

server.tool(
  "project_overview",
  "Main project dashboard with a summary of files, git, and dependencies. Shows key metrics and allows drill-down by area.",
  {
    directory: z.string().default(".").describe("The project directory to analyze"),
  },
  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 files found in \`${targetDir}\`` }],
      };
    }

    const stats = getFileStats(files);
    const git = getGitInfo(targetDir);
    const deps = await analyzeDeps(targetDir);

    let dashboard = `## 📊 Project Analytics Dashboard\n\n`;
    dashboard += `**Directory:** \`${targetDir}\`\n`;
    dashboard += `**Timestamp:** ${new Date().toLocaleString()}\n\n`;

    // KPIs
    const kpis = [
      `📁 ${files.length} files`,
      `📏 ${stats.totalLines.toLocaleString()} lines`,
      `💾 ${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("Files by type", fileTable, "📁");
    }

    // Git section
    if (config.showGit && git) {
      let gitContent = "";
      if (git.status.length > 0) {
        gitContent += `**Working tree:** ⚠️ ${git.status.length} pending changes\n\n`;
      } else {
        gitContent += `**Working tree:** ✅ Clean\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("Dependencies", depsContent, "📦");
    }

    // Footer with drill-down
    dashboard += `\n### 🔍 Drill-down\n\n`;
    dashboard += `- \`file_analysis\` — File detail (largest, most lines, recent)\n`;
    dashboard += `- \`git_analysis\` — Git detail (all commits, full contributor stats)\n`;
    dashboard += `- \`deps_analysis\` — Dependencies detail (all deps, versions)\n`;
    dashboard += `- \`configure_dashboard\` — Configure which sections to show\n`;

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

// ── Tool: File drill-down ──

server.tool(
  "file_analysis",
  "Detailed analysis of the project's files: distribution, largest files, most lines, and most recent",
  {
    directory: z.string().default(".").describe("Directory to analyze"),
    topN: z.number().int().positive().default(10).describe("Top N items per section"),
  },
  async ({ directory, topN }) => {
    const files = await analyzeFiles(directory);
    const stats = getFileStats(files);

    let output = `## 📁 File Analysis — Detail\n\n`;
    output += `**Total:** ${files.length} files | ${stats.totalLines.toLocaleString()} lines | ${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 += `### Full distribution\n\n`;
    output += markdownTable(["Ext", "Files", "Lines", "Size", "Bar"], extRows);

    // Largest files
    output += `\n\n---\n\n### 📏 Top ${topN} largest files\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} most lines of code\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### 🕐 Recently modified\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*Return to the overview with \`project_overview\`*`;

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

// ── Tool: Git drill-down ──

server.tool(
  "git_analysis",
  "Detailed git analysis: commit history, contributors, and working tree status",
  {
    directory: z.string().default(".").describe("The repository directory"),
  },
  async ({ directory }) => {
    const git = getGitInfo(directory);

    if (!git) {
      return {
        content: [{ type: "text" as const, text: `## 🔀 Git Analysis\n\n⚠️ \`${directory}\` is not a git repository` }],
      };
    }

    let output = `## 🔀 Git Analysis — Detail\n\n`;
    output += `**Branch:** \`${git.branch}\`\n`;
    output += `**Working tree:** ${git.status.length === 0 ? "✅ Clean" : `⚠️ ${git.status.length} changes`}\n\n---\n\n`;

    // Commits
    if (git.commits.length > 0) {
      output += `### 📝 Latest 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### 📋 Pending changes\n\n`;
      for (const line of git.status.slice(0, 15)) {
        output += `- \`${line}\`\n`;
      }
      if (git.status.length > 15) {
        output += `\n*...and ${git.status.length - 15} more*\n`;
      }
    }

    output += `\n\n---\n\n*Return to the overview with \`project_overview\`*`;

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

// ── Tool: Deps drill-down ──

server.tool(
  "deps_analysis",
  "Detailed analysis of the project's dependencies",
  {
    directory: z.string().default(".").describe("The project directory"),
  },
  async ({ directory }) => {
    const deps = await analyzeDeps(directory);

    if (!deps) {
      return {
        content: [{ type: "text" as const, text: `## 📦 Deps Analysis\n\n⚠️ No \`package.json\` found in \`${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*Return to the overview with \`project_overview\`*`;

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

// ── Tool: Configure dashboard ──

server.tool(
  "configure_dashboard",
  "Configures which sections to show in the dashboard overview. Without parameters it shows the current config.",
  {
    showFiles: z.boolean().optional().describe("Show the files section"),
    showGit: z.boolean().optional().describe("Show the git section"),
    showDeps: z.boolean().optional().describe("Show the dependencies section"),
    topN: z.number().int().min(3).max(20).optional().describe("Items per section (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\nPass parameters to change the configuration.`,
        }],
      };
    }

    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 Updated\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\nUse \`project_overview\` to see the dashboard with the new configuration.`,
      }],
    };
  }
);

// ── Start server ──

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

Step 6: Build and test with MCP Inspector

Compile

npm run build

If there are compilation errors, check that the imports use the .js extension (required for ESM with Node16 module resolution).

Test with MCP Inspector

npm run inspect

In MCP Inspector:

  1. Verify that the 5 tools appear: project_overview, file_analysis, git_analysis, deps_analysis, configure_dashboard
  2. Verify that the resource appears: analytics://config
  3. Run project_overview with directory pointing to a real project
  4. Verify that the dashboard has the expected sections
  5. Run the drill-downs to verify that they work
  6. Run configure_dashboard without parameters to see the current config
  7. Change the config and verify that project_overview reflects the changes

Step 7: Connect to Claude Code

Add the server

cd /path/to/project-analytics
npm run build

claude mcp add project-analytics node /path/to/project-analytics/build/server.js

Verify the connection

claude mcp list

You should see project-analytics in the list with a connected status.

Use the dashboard

Open Claude Code and try:

Show me the status of my project at /path/to/my-project

Claude Code should invoke project_overview and show you the formatted dashboard.

Try the drill-down:

Give me more detail about the project's files

Claude Code will invoke file_analysis and show you the detailed analysis.

Try the configuration:

Configure the dashboard so it doesn't show dependencies and shows 15 items per section

Claude Code will invoke configure_dashboard with the appropriate parameters.


Python version (alternative)

If you prefer to implement the project in Python, the structure is equivalent. The same patterns apply — FastMCP, @mcp.tool() decorators, and helper functions for formatting. The main difference is that Python uses os.walk() instead of a recursive walk function, and subprocess.run() instead of execSync for git commands.

The file structure would be:

project-analytics-py/
├── server.py              # Everything in one file (or split into modules)
├── requirements.txt       # mcp[cli]
└── .venv/

To connect:

pip install "mcp[cli]"
claude mcp add project-analytics python /path/to/server.py

The tools and their parameters are identical — project_overview, file_analysis, git_analysis, deps_analysis, configure_dashboard. What changes is the syntax, not the design. Review capsules 02-04 to see the Python examples of each pattern.


Final project checklist

Before considering the project complete, verify:

  • The server compiles without errors: npm run build (or python server.py without crashing)
  • MCP Inspector shows the 5 tools and the resource
  • project_overview returns a formatted dashboard with sections
  • file_analysis returns a detailed file drill-down
  • git_analysis returns git information (or an appropriate message if it's not a repo)
  • deps_analysis returns dependencies (or a message if there's no package.json)
  • configure_dashboard without params shows the config, with params updates it
  • The dashboard reflects configuration changes
  • The server is connected to Claude Code via claude mcp add
  • Claude Code can invoke project_overview and shows the dashboard
  • The drill-downs work when more details are requested
  • The data is real — you analyzed a real project, not example data

Connection to Module 8

This project demonstrates the pattern of an MCP App with an interactive dashboard. In the capstone project in module 8, you can incorporate these patterns:

This module (M6)Capstone project (M8)
Files/git dashboardDashboard of data from your database/API
Drill-down by areaDrill-down by entity/table
In-memory configurationPersistent configuration
No testsComplete test suite (module 7)
Static formatted outputOutput with real-time data

The formatting patterns — tables, bars, sections, drill-down — apply directly. What changes is the data source.


Troubleshooting

"The dashboard is empty"

Cause: The directory has no files or is excluded by SKIP_DIRS.

Solution: Verify that directory points to the correct path and that it's not a directory in the exclusion list.

"Git analysis returns null"

Cause: The directory is not a git repository.

Solution: Make sure the directory (or a parent) is an initialized git repo.

"The formatting breaks with files with special characters"

Cause: File names with | or backticks break the markdown tables.

Solution:

const safeName = name.replace(/\|/g, "\\|").replace(/`/g, "'");

"execSync timeout"

Cause: Very large repositories cause the git commands to take more than 5 seconds.

Solution: Increase the timeout or limit the range of analyzed commits.


Module summary

Throughout this module's 5 capsules, you learned:

  1. Capsule 01: What MCP Apps are, their capabilities and limitations, and when to use them vs web apps
  2. Capsule 02: The anatomy of a tool response, formatting patterns (tables, charts, indicators), reusable helpers
  3. Capsule 03: Complete dashboards (project status, DB analytics, system health), the drill-down pattern
  4. Capsule 04: Interactivity (preview/execute, multi-step workflows, prompts as orchestrators, data capture)
  5. Capsule 05: Project — an MCP App with an interactive analytics dashboard connected to Claude Code

What you can now do

  • ✅ Design tools that return visually rich and useful output
  • ✅ Build dashboards with tables, ASCII charts, and indicators
  • ✅ Implement drill-down for navigation from the summary to the detail
  • ✅ Create multi-step workflows with confirmations
  • ✅ Combine tools and prompts for interactive flows
  • ✅ Know when to use MCP Apps and when you need a web app
  • ✅ Have a functional MCP App running in Claude Code

What's coming

  • Module 7: Testing, Debugging, and Integration — automated testing of MCP servers, advanced MCP Inspector, troubleshooting
  • Module 8: Capstone Project — a production-ready MCP server with a real database, where you can apply this module's dashboard patterns

Additional resources

  1. MCP TypeScript SDK — Official SDK
  2. MCP Python SDK — Official SDK
  3. MCP Inspector — Visual debugging
  4. Node.js child_process — To run git commands
  5. MCP Specification — Protocol specification
  6. ASCII Charts Inspiration — Techniques for visualization in the terminal
  7. Markdown Guide — Markdown format reference
  8. Claude Code MCP Configuration — Configure MCP in Claude Code