Module 6: MCP Apps and Interactive UI
MCP Apps: Tools That Return Visual Output
MCP Apps: Tools That Return Visual Output
Capsule description
In the module's introduction you saw the general idea: MCP Apps are MCP servers whose tools return visually rich output. Now let's go to the technical part. How does this work exactly? What can you return from a tool? How do you decide between plain text, JSON, and formatted markdown?
This capsule answers those questions with code. You're going to see the complete anatomy of a tool response, the content types you can use, and the patterns to transform raw data into output that a developer wants to read. By the end, you'll have the technical foundations to build dashboards and interactive flows in the following capsules.
Anatomy of a tool response in MCP
The base structure
Every tool in MCP returns a CallToolResult object. In its simplest form:
// TypeScript
return {
content: [
{
type: "text",
text: "Operation completed successfully",
},
],
};
# Python with FastMCP — simplified return
@mcp.tool()
async def my_tool() -> str:
"""My tool."""
return "Operation completed successfully"
The Python SDK simplifies this: when you return a str, FastMCP automatically wraps it in { content: [{ type: "text", text: "..." }] }. In TypeScript, you build the structure explicitly.
Available content types
The MCP protocol defines several content types in responses:
// Type "text" — the most used
{
type: "text",
text: "Any text: plain, markdown, JSON, tables, ASCII art..."
}
// Type "image" — for inline images
{
type: "image",
data: "base64_encoded_image_data...",
mimeType: "image/png"
}
// Type "resource" — reference to a server resource
{
type: "resource",
resource: {
uri: "myserver://data/report",
mimeType: "application/json",
text: '{"key": "value"}'
}
}
In practice, for MCP Apps, type: "text" is your main tool. The magic is in what you put inside the text field — formatted markdown, tables, reports, and complete dashboards.
Multiple content blocks
A tool can return multiple blocks in its content array — for example, a text block + an image block. In practice, a single text block with well-structured markdown is enough for most MCP Apps and more manageable than fragmenting the content.
From raw data to rich output: the process
Step 1: Get the data
First, your tool gets data from the source — a database, an API, the filesystem, whatever:
@mcp.tool()
async def project_status() -> str:
"""Shows the project's current status."""
# Step 1: Get data
files = count_files_by_type("./src")
tests = get_test_results()
git_info = get_recent_commits(5)
Step 2: Process and aggregate
Transform the raw data into useful metrics:
# Step 2: Process
total_files = sum(files.values())
test_pass_rate = tests["passed"] / tests["total"] * 100 if tests["total"] > 0 else 0
lines_changed_today = sum(c["lines_changed"] for c in git_info)
Step 3: Format as rich output
This is where the magic happens — you build the formatted output:
# Step 3: Format
dashboard = f"""## 📊 Project Status
**Summary:** {total_files} files | {tests["total"]} tests | {len(git_info)} recent commits
---
### 📁 Files by type
| Type | Count | % of total |
|------|----------|-------------|
"""
for ext, count in sorted(files.items(), key=lambda x: -x[1]):
pct = count / total_files * 100
bar = "█" * int(pct / 5) + "░" * (20 - int(pct / 5))
dashboard += f"| {ext} | {count} | {bar} {pct:.1f}% |\n"
dashboard += f"""
---
### 🧪 Tests
| Metric | Value |
|---------|-------|
| Total | {tests["total"]} |
| Passing | ✅ {tests["passed"]} |
| Failing | ❌ {tests["failed"]} |
| Pass rate | {"🟢" if test_pass_rate > 90 else "🟡" if test_pass_rate > 70 else "🔴"} {test_pass_rate:.1f}% |
---
### 📝 Recent commits
"""
for commit in git_info:
dashboard += f"- `{commit['hash'][:7]}` {commit['message']} ({commit['author']}, {commit['time_ago']})\n"
dashboard += f"\n**Lines changed today:** {lines_changed_today:,}"
return dashboard
The result
Claude Code receives that string and presents it as rendered markdown. The user sees an organized dashboard with tables, progress bars, indicator emojis, and clear sections — not a blob of JSON.
Formatting patterns
Pattern 1: Markdown tables for tabular data
Markdown tables are the most versatile format for structured data:
function formatAsTable(
headers: string[],
rows: string[][],
alignment?: ("left" | "center" | "right")[]
): string {
const headerRow = `| ${headers.join(" | ")} |`;
const separatorRow = `| ${headers.map((_, i) => {
const align = alignment?.[i] || "left";
if (align === "center") return ":---:";
if (align === "right") return "---:";
return "---";
}).join(" | ")} |`;
const dataRows = rows.map(row => `| ${row.join(" | ")} |`).join("\n");
return `${headerRow}\n${separatorRow}\n${dataRows}`;
}
server.tool(
"list_endpoints",
"Lists the API's endpoints with their current status",
{},
async () => {
const endpoints = await getEndpoints();
const headers = ["Endpoint", "Method", "Status", "Latency", "Req/24h"];
const rows = endpoints.map(ep => [
ep.path,
ep.method,
ep.status === "healthy" ? "✅ OK" : "⚠️ Degraded",
`${ep.latency_ms}ms`,
ep.requests_24h.toLocaleString(),
]);
const table = formatAsTable(headers, rows);
return {
content: [{
type: "text" as const,
text: `## API Endpoints\n\n${table}\n\n**Total:** ${endpoints.length} endpoints`,
}],
};
}
);
Pattern 2: Visual indicators with emojis and Unicode
Emojis and Unicode characters are your visual palette in the terminal:
def status_indicator(value: float, thresholds: tuple[float, float] = (70, 90)) -> str:
"""Returns a visual indicator based on thresholds."""
low, high = thresholds
if value >= high:
return f"🟢 {value:.1f}%"
elif value >= low:
return f"🟡 {value:.1f}%"
else:
return f"🔴 {value:.1f}%"
def progress_bar(current: int, total: int, width: int = 20) -> str:
"""Generates an ASCII progress bar."""
if total == 0:
return "░" * width + " 0%"
filled = int(current / total * width)
bar = "█" * filled + "░" * (width - filled)
pct = current / total * 100
return f"{bar} {pct:.0f}%"
def trend_arrow(current: float, previous: float) -> str:
"""Indicates the trend with arrows."""
if current > previous * 1.05:
return f"↑ +{((current - previous) / previous * 100):.1f}%"
elif current < previous * 0.95:
return f"↓ {((current - previous) / previous * 100):.1f}%"
else:
return "→ stable"
These helpers transform abstract numbers into immediate visual information.
Pattern 3: Sections with headers and separators
Organize long dashboards with a helper that standardizes the format of each section:
def format_section(title: str, content: str, emoji: str = "📋") -> str:
return f"\n### {emoji} {title}\n\n{content}\n\n---"
This lets you build multi-section dashboards by composing independent sections, each with its own emoji, title, and content. A general header with a summary + individual sections + a footer with suggested actions is the structure that works best.
Pattern 4: ASCII charts for quick visualization
When you need a visualization but can't render graphics:
function asciiBarChart(
data: { label: string; value: number }[],
maxWidth: number = 30
): string {
const maxValue = Math.max(...data.map(d => d.value));
const maxLabelLen = Math.max(...data.map(d => d.label.length));
return data.map(({ label, value }) => {
const barLength = maxValue > 0 ? Math.round((value / maxValue) * maxWidth) : 0;
const bar = "█".repeat(barLength) + "░".repeat(maxWidth - barLength);
const paddedLabel = label.padEnd(maxLabelLen);
return `${paddedLabel} ${bar} ${value.toLocaleString()}`;
}).join("\n");
}
// Usage:
const chart = asciiBarChart([
{ label: "TypeScript", value: 45 },
{ label: "Python", value: 32 },
{ label: "Markdown", value: 18 },
{ label: "JSON", value: 12 },
{ label: "YAML", value: 5 },
]);
// Output:
// TypeScript ██████████████████████████████ 45
// Python █████████████████████▒░░░░░░░░ 32
// Markdown ████████████░░░░░░░░░░░░░░░░░░ 18
// JSON ████████░░░░░░░░░░░░░░░░░░░░░░ 12
// YAML ███░░░░░░░░░░░░░░░░░░░░░░░░░░░ 5
In Python you can use the same block characters. An additional useful helper is the sparkline — a compact visual trend:
def ascii_sparkline(values: list[float]) -> str:
"""Generates a sparkline with Unicode characters."""
if not values:
return ""
chars = "▁▂▃▄▅▆▇█"
min_val, max_val = min(values), max(values)
range_val = max_val - min_val if max_val != min_val else 1
return "".join(chars[min(int((v - min_val) / range_val * 7), 7)] for v in values)
# Usage: ascii_sparkline([1, 3, 7, 5, 2, 8, 4]) → "▁▃▆▅▂█▃"
Complete example: Health Check MCP App
Let's look at an end-to-end example — an MCP server that works as a service health checker:
TypeScript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({ name: "health-checker", version: "1.0.0" });
async function checkService(name: string, url: string) {
const start = Date.now();
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const response = await fetch(url, { method: "HEAD", signal: controller.signal });
clearTimeout(timeout);
const elapsed = Date.now() - start;
return { name, url, status: elapsed > 2000 ? "slow" as const : "up" as const,
responseTimeMs: elapsed, statusCode: response.status };
} catch {
return { name, url, status: "down" as const, responseTimeMs: Date.now() - start };
}
}
const STATUS_EMOJI = { up: "✅", slow: "⚠️", down: "❌" } as const;
function latencyBar(ms: number, maxMs = 5000, width = 15): string {
const filled = Math.min(Math.round((ms / maxMs) * width), width);
return "█".repeat(filled) + "░".repeat(width - filled);
}
server.tool(
"check_all_services",
"Checks the status of services and shows a health dashboard",
{
services: z.array(z.object({
name: z.string().describe("Service name"),
url: z.string().url().describe("URL to check"),
})).min(1).describe("List of services to check"),
},
async ({ services }) => {
const results = await Promise.all(
services.map(svc => checkService(svc.name, svc.url))
);
const up = results.filter(r => r.status === "up").length;
const slow = results.filter(r => r.status === "slow").length;
const down = results.filter(r => r.status === "down").length;
let output = `## 🏥 Service Health Dashboard
**Services:** ${results.length} total | ✅ ${up} up | ⚠️ ${slow} slow | ❌ ${down} down
| Service | Status | Latency | Code | Bar |
|---------|--------|---------|------|-----|
`;
for (const svc of results) {
output += `| ${svc.name} | ${STATUS_EMOJI[svc.status]} ${svc.status} | ${svc.responseTimeMs}ms | ${svc.statusCode || "N/A"} | ${latencyBar(svc.responseTimeMs)} |\n`;
}
const downSvcs = results.filter(r => r.status === "down");
if (downSvcs.length > 0) {
output += `\n### ❌ Down services\n\n`;
for (const svc of downSvcs) output += `- **${svc.name}** (${svc.url})\n`;
}
return { content: [{ type: "text" as const, text: output }] };
}
);
const transport = new StdioServerTransport();
await server.connect(transport);
Python equivalent (summary)
The same logic in Python with FastMCP — the formatting is identical, only the syntax changes:
from mcp.server.fastmcp import FastMCP
import httpx, asyncio, time
from datetime import datetime
mcp = FastMCP("health-checker")
STATUS_EMOJI = {"up": "✅", "slow": "⚠️", "down": "❌"}
async def check_service(name: str, url: str) -> dict:
start = time.time()
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.head(url)
elapsed = int((time.time() - start) * 1000)
return {"name": name, "url": url, "status": "slow" if elapsed > 2000 else "up",
"response_time_ms": elapsed, "status_code": response.status_code}
except Exception:
return {"name": name, "url": url, "status": "down",
"response_time_ms": int((time.time() - start) * 1000), "status_code": None}
@mcp.tool()
async def check_all_services(urls: list[str], names: list[str] | None = None) -> str:
"""Checks the status of a list of URLs and shows a health dashboard."""
service_names = names or [f"Service {i+1}" for i in range(len(urls))]
results = await asyncio.gather(*[check_service(n, u) for n, u in zip(service_names, urls)])
up = sum(1 for r in results if r["status"] == "up")
slow = sum(1 for r in results if r["status"] == "slow")
down = sum(1 for r in results if r["status"] == "down")
lines = [
f"## 🏥 Service Health Dashboard\n",
f"**Services:** {len(results)} total | ✅ {up} up | ⚠️ {slow} slow | ❌ {down} down\n",
"| Service | Status | Latency | Code |",
"|---------|--------|---------|------|",
]
for r in results:
emoji = STATUS_EMOJI.get(r["status"], "❓")
lines.append(f"| {r['name']} | {emoji} {r['status']} | {r['response_time_ms']}ms | {r['status_code'] or 'N/A'} |")
return "\n".join(lines)
if __name__ == "__main__":
mcp.run()
What this example demonstrates
- Complete dashboard formatting — a header with a summary, a detailed table, conditional sections
- Visual indicators — emojis for status, ASCII bars for latency
- Conditional sections — only shows "Down services" if any are down
- Actionable information — not just data, but what to do with it
- Both languages — same output, idiomatic implementation in each
When to format and when not to
Format when there's tabular data, numeric metrics, or alerts that need to be highlighted. Don't format when the result is a simple value, when the output will be processed by another tool (JSON is better), or when you're debugging (raw JSON is more useful for inspection).
Exercises
Exercise 1: Formatting helper (Easy)
Implement a format_file_tree function that takes a directory structure as an array of { name, type, size?, children? } objects and formats it as a visual tree.
See solution
interface FileNode {
name: string;
type: "file" | "directory";
size?: number;
children?: FileNode[];
}
function formatFileTree(nodes: FileNode[], prefix: string = "", isLast: boolean = true): string {
let result = "";
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
const isLastNode = i === nodes.length - 1;
const connector = isLastNode ? "└── " : "├── ";
const icon = node.type === "directory" ? "📁" : "📄";
const size = node.size ? ` (${(node.size / 1024).toFixed(1)} KB)` : "";
result += `${prefix}${connector}${icon} ${node.name}${size}\n`;
if (node.children && node.children.length > 0) {
const childPrefix = prefix + (isLastNode ? " " : "│ ");
result += formatFileTree(node.children, childPrefix, isLastNode);
}
}
return result;
}
// Example output:
// ├── 📁 src
// │ ├── 📄 index.ts (2.3 KB)
// │ ├── 📁 tools
// │ │ ├── 📄 search.ts (1.8 KB)
// │ │ └── 📄 files.ts (3.1 KB)
// │ └── 📄 utils.ts (0.9 KB)
// └── 📄 package.json (0.5 KB)
Exercise 2: Metrics dashboard (Medium)
Create a show_metrics tool that takes an array of metrics { name, current, previous, unit } and returns a dashboard with a table, trend arrows, and progress bars.
See solution
from mcp.server.fastmcp import FastMCP
from pydantic import BaseModel, Field
import json
mcp = FastMCP("metrics-dashboard")
class Metric(BaseModel):
name: str = Field(description="Metric name")
current: float = Field(description="Current value")
previous: float = Field(description="Previous value (to calculate the trend)")
unit: str = Field(default="", description="Unit (%, ms, count, etc.)")
def trend_arrow(current: float, previous: float) -> str:
if previous == 0:
return "→ N/A"
change = ((current - previous) / previous) * 100
if change > 5:
return f"↑ +{change:.1f}%"
elif change < -5:
return f"↓ {change:.1f}%"
return f"→ {change:+.1f}%"
def mini_bar(value: float, max_value: float, width: int = 10) -> str:
filled = min(int(value / max_value * width), width) if max_value > 0 else 0
return "█" * filled + "░" * (width - filled)
@mcp.tool()
async def show_metrics(metrics: list[Metric]) -> str:
"""Shows a metrics dashboard with trends and progress bars."""
max_val = max(m.current for m in metrics) if metrics else 1
lines = [
"## 📈 Metrics Dashboard",
"",
f"**{len(metrics)} metrics** | Updated: now",
"",
"| Metric | Current | Previous | Trend | Visual |",
"|---------|--------|----------|-----------|--------|",
]
for m in metrics:
trend = trend_arrow(m.current, m.previous)
bar = mini_bar(m.current, max_val)
lines.append(
f"| {m.name} | {m.current:.1f}{m.unit} | {m.previous:.1f}{m.unit} | {trend} | {bar} |"
)
improving = [m for m in metrics if m.current > m.previous * 1.05]
declining = [m for m in metrics if m.current < m.previous * 0.95]
if improving:
lines.extend(["", "### 📈 Improving"])
for m in improving:
lines.append(f"- **{m.name}**: {m.previous:.1f} → {m.current:.1f}{m.unit}")
if declining:
lines.extend(["", "### 📉 Declining"])
for m in declining:
lines.append(f"- **{m.name}**: {m.previous:.1f} → {m.current:.1f}{m.unit}")
return "\n".join(lines)
if __name__ == "__main__":
mcp.run()
Exercise 3: Conditional formatting (Medium)
Implement a format_log_entries tool (TypeScript) that takes an array of log entries { timestamp, severity, message, source? } where severity is "debug" | "info" | "warn" | "error" | "fatal". Group the entries by severity (from most severe to least), use emojis for each level, and show a count summary at the top.
See solution
const SEVERITY = {
fatal: { emoji: "💀", label: "FATAL", order: 0 },
error: { emoji: "❌", label: "ERROR", order: 1 },
warn: { emoji: "⚠️", label: "WARN", order: 2 },
info: { emoji: "ℹ️", label: "INFO", order: 3 },
debug: { emoji: "🔍", label: "DEBUG", order: 4 },
};
server.tool(
"format_log_entries",
"Formats log entries grouped by severity",
{
entries: z.array(z.object({
timestamp: z.string(), severity: z.enum(["debug","info","warn","error","fatal"]),
message: z.string(), source: z.string().optional(),
})).min(1),
},
async ({ entries }) => {
const grouped = new Map<string, typeof entries>();
for (const e of entries) {
if (!grouped.has(e.severity)) grouped.set(e.severity, []);
grouped.get(e.severity)!.push(e);
}
const keys = [...grouped.keys()].sort((a, b) => SEVERITY[a].order - SEVERITY[b].order);
let out = `## 📋 Log Viewer\n\n**${entries.length} entries:** `;
out += keys.map(k => `${SEVERITY[k].emoji} ${grouped.get(k)!.length}`).join(" | ");
out += "\n\n---\n\n";
for (const sev of keys) {
const { emoji, label } = SEVERITY[sev];
out += `### ${emoji} ${label} (${grouped.get(sev)!.length})\n\n`;
for (const e of grouped.get(sev)!) {
const time = e.timestamp.split("T")[1]?.split(".")[0] || e.timestamp;
out += `- \`${time}\`${e.source ? ` [${e.source}]` : ""} ${e.message}\n`;
}
out += "\n";
}
return { content: [{ type: "text" as const, text: out }] };
}
);
Exercise 4: Multi-section report (Hard)
Create a generate_project_report tool that analyzes a directory and returns a multi-section report with: file count by extension (with ASCII bars), the top 5 largest files, and the top 5 recently modified. Use os.walk to traverse the directory and exclude node_modules, .git, and __pycache__.
See solution
import os
from datetime import datetime, timedelta
from collections import defaultdict
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("project-reporter")
def size_str(b: int) -> str:
if b < 1024: return f"{b} B"
if b < 1024 * 1024: return f"{b/1024:.1f} KB"
return f"{b/1024/1024:.1f} MB"
def time_ago(ts: float) -> str:
delta = datetime.now() - datetime.fromtimestamp(ts)
if delta < timedelta(hours=1): return f"{delta.seconds // 60}min ago"
if delta < timedelta(days=1): return f"{delta.seconds // 3600}h ago"
return f"{delta.days}d ago"
@mcp.tool()
async def generate_project_report(directory: str) -> str:
"""Generates a multi-section report of a project directory."""
if not os.path.isdir(directory):
return f"Error: '{directory}' is not a valid directory"
skip = {"node_modules", ".git", "__pycache__", ".venv", "venv"}
files_by_ext: dict[str, int] = defaultdict(int)
all_files: list[dict] = []
total_size = 0
for root, dirs, files in os.walk(directory):
dirs[:] = [d for d in dirs if d not in skip]
for f in files:
filepath = os.path.join(root, f)
try:
stat = os.stat(filepath)
except OSError:
continue
ext = os.path.splitext(f)[1] or "(none)"
files_by_ext[ext] += 1
total_size += stat.st_size
all_files.append({"path": os.path.relpath(filepath, directory),
"size": stat.st_size, "modified": stat.st_mtime})
largest = sorted(all_files, key=lambda f: -f["size"])[:5]
recent = sorted(all_files, key=lambda f: -f["modified"])[:5]
sorted_exts = sorted(files_by_ext.items(), key=lambda x: -x[1])[:8]
max_count = max(files_by_ext.values()) if files_by_ext else 1
report = f"## 📊 Project Report\n\n"
report += f"**Directory:** `{directory}`\n"
report += f"**Total:** {len(all_files)} files | {size_str(total_size)}\n\n---\n\n"
report += "### 📁 By extension\n\n| Ext | Count | Distribution |\n|-----|------|-------------|\n"
for ext, count in sorted_exts:
bar = "█" * int(count / max_count * 12) + "░" * (12 - int(count / max_count * 12))
report += f"| `{ext}` | {count} | {bar} |\n"
report += "\n---\n\n### 📏 Largest\n\n| File | Size |\n|---------|--------|\n"
for f in largest:
report += f"| `{f['path']}` | {size_str(f['size'])} |\n"
report += "\n---\n\n### 🕐 Recent\n\n| File | Modified |\n|---------|----------|\n"
for f in recent:
report += f"| `{f['path']}` | {time_ago(f['modified'])} |\n"
return report
if __name__ == "__main__":
mcp.run()
Troubleshooting
"The markdown doesn't render correctly"
Cause: The text has formatting problems — misaligned pipes in tables, a missing header separator, or special characters.
Solution:
<!-- ❌ Broken table — missing separator -->
| Header1 | Header2 |
| data1 | data2 |
<!-- ✅ Correct table -->
| Header1 | Header2 |
|---------|---------|
| data1 | data2 |
Make sure each table has exactly the same number of | in each row, including the separator.
"The ASCII bars look misaligned"
Cause: Unicode characters of different widths (emojis, CJK characters) misalign the columns.
Solution: Use fixed-width characters for the bars (█, ░, ▓, ▒) and avoid mixing emojis inside sections that need precise alignment. Emojis work better in headers and labels.
"The output is too long and gets truncated"
Cause: You're returning too much data. Claude Code has limits on the length of the response.
Solution:
# Limit the number of items
results = sorted(data, key=lambda x: -x["value"])[:MAX_ITEMS]
# Add a footer indicating truncation
if len(data) > MAX_ITEMS:
output += f"\n*Showing the top {MAX_ITEMS} of {len(data)} total.*"
Summary
In this capsule you learned:
- The anatomy of a tool response — content types, multiple blocks,
type: "text"as the main format - The 3-step process — get data → process/aggregate → format as rich output
- 5 formatting patterns — markdown tables, indicators with emojis, sections with headers, ASCII charts, key-value pairs
- A complete example — a health checker with a dashboard in both languages
- When to format and when not to — rich formatting for complex/recurring data, plain text for simple values and debugging
Formatting the output isn't cosmetic — it's what turns a functional MCP server into one you use every day.
Additional resources
- MCP Specification — Content Types — Content types in MCP responses
- Markdown Guide — Tables — Markdown tables reference
- Unicode Block Characters — Characters for bars and charts
- MCP TypeScript SDK — Official SDK
- MCP Python SDK — Official SDK
- ASCII Art — Bar Charts — Techniques for charts in the terminal
Next capsule: Dashboards and Visualizations — building complete dashboards with multiple sections, advanced ASCII charts, and data reports that Claude Code presents visually.