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 areasfile_analysis— Drill-down: file distribution, sizes, complexitygit_analysis— Drill-down: git activity, contributors, commit frequencydeps_analysis— Drill-down: dependencies, versions, possible issuesconfigure_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:
- Verify that the 5 tools appear:
project_overview,file_analysis,git_analysis,deps_analysis,configure_dashboard - Verify that the resource appears:
analytics://config - Run
project_overviewwithdirectorypointing to a real project - Verify that the dashboard has the expected sections
- Run the drill-downs to verify that they work
- Run
configure_dashboardwithout parameters to see the current config - Change the config and verify that
project_overviewreflects 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(orpython server.pywithout crashing) - MCP Inspector shows the 5 tools and the resource
-
project_overviewreturns a formatted dashboard with sections -
file_analysisreturns a detailed file drill-down -
git_analysisreturns git information (or an appropriate message if it's not a repo) -
deps_analysisreturns dependencies (or a message if there's no package.json) -
configure_dashboardwithout 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_overviewand 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 dashboard | Dashboard of data from your database/API |
| Drill-down by area | Drill-down by entity/table |
| In-memory configuration | Persistent configuration |
| No tests | Complete test suite (module 7) |
| Static formatted output | Output 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:
- Capsule 01: What MCP Apps are, their capabilities and limitations, and when to use them vs web apps
- Capsule 02: The anatomy of a tool response, formatting patterns (tables, charts, indicators), reusable helpers
- Capsule 03: Complete dashboards (project status, DB analytics, system health), the drill-down pattern
- Capsule 04: Interactivity (preview/execute, multi-step workflows, prompts as orchestrators, data capture)
- 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
- MCP TypeScript SDK — Official SDK
- MCP Python SDK — Official SDK
- MCP Inspector — Visual debugging
- Node.js child_process — To run git commands
- MCP Specification — Protocol specification
- ASCII Charts Inspiration — Techniques for visualization in the terminal
- Markdown Guide — Markdown format reference
- Claude Code MCP Configuration — Configure MCP in Claude Code