I love GitHub Unwrapped because it lets me see all the progress (or lack of progress) I've made over the past year. Sidenote: I will be posting my GitHub Unwrapped on December 31st.
I wanted to take that idea one step further.
So instead of just looking at my stats, I built an MCP App that:
- Pulls your GitHub activity for 2025
- Shows you your commits, repos, top languages, and more
- Gives you one roast
- And one compliment
Here's my repo.
What Are MCP Apps and Why I Care
I've been following the MCP (Model Context Protocol) ecosystem, you know that MCP servers have been limited to text and structured data. That's great for a lot of things, but what if your tool needs to show something visual? Or collect complex input from a user?
That's where MCP Apps come in.
MCP Apps are a new extension to MCP that lets servers render interactive UIs right inside the host application. The spec just dropped a few weeks ago, but it's one of the most requested features from the community.
MCP-UI was it's precursor, and I've played around with that a ton because my company build an AI agent called goose that actually has MCP support.
Here's a video of me showing off goose and MCP-UI, but I'm excited that it's going to become an official standard now.
I wanted to learn if there were any differences between building MCP Apps and MCP-UI. (And I secretly want to get more involved with shaping the future of MCP Apps), so I decided to build something fun and quick. Hence: the GitHub Year in Review with roasts. I also took this as an opportunity to learn a little more about MCP resources. I've used MCP servers before, but never had to set them up.
What I Built
The app has three MCP patterns working together:
Resource → github://user/{username}/2025-review
This exposes the cached review data so other tools or LLMs can read it.
Tool → get-year-in-review
This does the heavy lifting—fetches your GitHub data, crunches the stats, and generates your roast and compliment.
Prompt → year-in-review
A template that helps LLMs know how to ask for a review.
Plus an interactive UI that lets you type in any GitHub username and see the results.
Let's Build It
Project Setup
First, create a new directory and set things up:
mkdir github-year-review && cd github-year-review
npm init -y
Install the dependencies:
npm install @modelcontextprotocol/sdk @modelcontextprotocol/ext-apps zod
npm install -D typescript vite vite-plugin-singlefile express cors @types/express @types/cors tsx
Create tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist"
},
"include": ["*.ts", "src/**/*.ts"]
}
Create vite.config.ts:
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";
export default defineConfig({
plugins: [viteSingleFile()],
build: {
outDir: "dist",
rollupOptions: {
input: process.env.INPUT,
},
},
});
Update your package.json scripts:
{
"type": "module",
"scripts": {
"build": "INPUT=mcp-app.html vite build",
"serve": "npx tsx server.ts"
}
}
The Server
Create server.ts. I'll walk through the important parts.
First, the setup and GitHub auth:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { type McpUiToolMeta } from "@modelcontextprotocol/ext-apps";
import cors from "cors";
import express from "express";
import fs from "node:fs/promises";
import path from "node:path";
import * as z from "zod";
const server = new McpServer({
name: "GitHub Year in Review 2025",
version: "1.0.0",
});
// GitHub token is optional but gives you higher rate limits
const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
const githubHeaders: Record<string, string> = {
"User-Agent": "MCP-GitHub-Review",
"Accept": "application/vnd.github.v3+json",
};
if (GITHUB_TOKEN) {
githubHeaders["Authorization"] = `Bearer ${GITHUB_TOKEN}`;
console.log("✅ GitHub token found");
} else {
console.log("⚠️ No GITHUB_TOKEN - you'll hit rate limits faster");
}
const userCache = new Map<string, any>();
Fetching GitHub Data
Here's where I fetch the data. I use the GraphQL API when possible because it gives more accurate contribution counts:
async function fetchGitHubUser(username: string) {
const response = await fetch(`https://api.github.com/users/${username}`, {
headers: githubHeaders,
});
if (!response.ok) throw new Error(`User not found: ${username}`);
return response.json();
}
async function fetchGitHub2025Activity(username: string) {
// Fetch repos
const reposResponse = await fetch(
`https://api.github.com/users/${username}/repos?per_page=100&sort=updated`,
{ headers: githubHeaders }
);
const repos = await reposResponse.json();
let totalContributions = 0;
let totalCommits = 0;
// GraphQL gives us the real contribution count
if (GITHUB_TOKEN) {
const graphqlQuery = {
query: `query($username: String!) {
user(login: $username) {
contributionsCollection(from: "2025-01-01T00:00:00Z", to: "2025-12-31T23:59:59Z") {
contributionCalendar { totalContributions }
totalCommitContributions
}
}
}`,
variables: { username },
};
const graphqlResponse = await fetch("https://api.github.com/graphql", {
method: "POST",
headers: { ...githubHeaders, "Content-Type": "application/json" },
body: JSON.stringify(graphqlQuery),
});
const data = await graphqlResponse.json();
const contributions = data?.data?.user?.contributionsCollection;
if (contributions) {
totalContributions = contributions.contributionCalendar?.totalContributions || 0;
totalCommits = contributions.totalCommitContributions || 0;
}
}
// Fallback if no token
if (totalContributions === 0) {
const commitsResponse = await fetch(
`https://api.github.com/search/commits?q=author:${username}+committer-date:2025-01-01..2025-12-31&per_page=1`,
{ headers: { ...githubHeaders, Accept: "application/vnd.github.cloak-preview" } }
);
const commitsData = await commitsResponse.json();
totalCommits = commitsData.total_count || 0;
totalContributions = totalCommits;
}
// Get top languages from repos
const languages: Record<string, number> = {};
repos.forEach((repo: any) => {
if (repo.language) {
languages[repo.language] = (languages[repo.language] || 0) + 1;
}
});
const topLanguages = Object.entries(languages)
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([lang]) => lang);
// Count repos created in 2025
const repos2025 = repos.filter((repo: any) =>
new Date(repo.created_at).getFullYear() === 2025
);
return {
totalContributions,
totalCommits,
reposCreated: repos2025.length,
topLanguages,
starsEarned: repos2025.reduce((sum: number, repo: any) => sum + (repo.stargazers_count || 0), 0),
};
}
A Quick Note on Data Accuracy
I spent way too long trying to get the contribution count to match what I see on my GitHub profile. Turns out, the API only shows public contributions—if you're in private orgs (like I am at Block), those don't show up unless you have special token permissions.
I built this to learn MCP Apps, not to solve GitHub's API quirks, so I moved on. Just know your numbers might be lower than expected if you work in private repos!
The Roast and Compliment Generator
This is the fun part:
function generateRoastAndCompliment(profile: any, stats: any) {
const roasts = [];
const compliments = [];
// Roasts
if (stats.totalContributions < 50) {
roasts.push(`${stats.totalContributions} contributions in 2025? Your GitHub is basically a digital ghost town. 👻`);
} else if (stats.totalContributions > 2000) {
roasts.push(`${stats.totalContributions} contributions? Either you're a machine or you need to touch grass. 🤖`);
}
if (stats.reposCreated === 0) {
roasts.push("Zero new repos in 2025? Living dangerously by not starting projects you'll never finish.");
}
if (stats.topLanguages.includes("JavaScript") && !stats.topLanguages.includes("TypeScript")) {
roasts.push("Still writing JavaScript without TypeScript in 2025? Living life on hard mode. 💀");
}
if (!profile.bio) {
roasts.push("No bio? The mystery is not as intriguing as you think. 🕵️");
}
// Compliments
if (stats.totalContributions > 500) {
compliments.push(`${stats.totalContributions} contributions shows real dedication. You actually showed up this year! 💪`);
}
if (stats.starsEarned > 0) {
compliments.push(`${stats.starsEarned} stars earned in 2025! People actually like what you're building. ⭐`);
}
if (stats.topLanguages.length >= 3) {
compliments.push(`Polyglot energy! You worked with ${stats.topLanguages.slice(0, 3).join(", ")} this year. 👑`);
}
if (stats.topLanguages.includes("TypeScript")) {
compliments.push("TypeScript user? You believe in type safety and sanity. A true professional. ✨");
}
if (stats.topLanguages.includes("Rust")) {
compliments.push("Rust in your stack? Big brain energy. 🦀");
}
const roast = roasts[Math.floor(Math.random() * roasts.length)]
|| "Your GitHub is so vanilla I can't even roast it. 😐";
const compliment = compliments[Math.floor(Math.random() * compliments.length)]
|| "You have a GitHub account and that's more than most people! 🏟️";
return { roast, compliment };
}
Registering the MCP Stuff
Now we wire up the Resource, Tool, and Prompt:
// Resource - exposes the cached data
server.registerResource(
"github://user/{username}/2025-review",
"github://user/{username}/2025-review",
{ mimeType: "application/json" },
async (uri) => {
const match = uri.href.match(/github:\/\/user\/([^/]+)\/2025-review/);
const username = match?.[1];
if (!username || !userCache.has(username)) {
return {
contents: [{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify({ error: "Call get-year-in-review first" }),
}],
};
}
return {
contents: [{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify(userCache.get(username)),
}],
};
}
);
// Tool - this is what actually does the work
const resourceUri = "ui://github-review/mcp-app.html";
server.registerTool(
"get-year-in-review",
{
title: "Get GitHub Year in Review",
description: "Fetches a GitHub user's 2025 activity and generates a roast and compliment.",
inputSchema: {
username: z.string().describe("GitHub username to review"),
},
_meta: { ui: { resourceUri } as McpUiToolMeta },
},
async ({ username }) => {
const profile = await fetchGitHubUser(username);
const stats = await fetchGitHub2025Activity(username);
const { roast, compliment } = generateRoastAndCompliment(profile, stats);
const review = {
username: profile.login,
avatarUrl: profile.avatar_url,
name: profile.name,
stats,
roast,
compliment,
};
userCache.set(username, review);
return {
content: [{ type: "text", text: `${roast} | ${compliment}` }],
structuredContent: review,
};
}
);
// Prompt - helps LLMs know how to use this
server.registerPrompt(
"year-in-review",
{
title: "GitHub Year in Review",
description: "Generate a year in review for a GitHub user.",
inputSchema: {
username: z.string().describe("GitHub username"),
},
},
async ({ username }) => ({
messages: [{
role: "user",
content: {
type: "text",
text: `Use get-year-in-review to fetch the 2025 GitHub Year in Review for "${username}".`,
},
}],
})
);
// UI Resource - serves the HTML
server.registerResource(
resourceUri,
resourceUri,
{ mimeType: "text/html;profile=mcp-app" },
async () => {
const html = await fs.readFile(
path.join(import.meta.dirname, "dist", "mcp-app.html"),
"utf-8"
);
return {
contents: [{ uri: resourceUri, mimeType: "text/html;profile=mcp-app", text: html }],
};
}
);
Express Server
Finally, set up the Express server:
const app = express();
app.use(cors());
app.use(express.json());
app.post("/mcp", async (req, res) => {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableJsonResponse: true,
});
res.on("close", () => transport.close());
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
app.listen(3001, () => {
console.log("🎉 Server running at http://localhost:3001/mcp");
});
The UI
Create mcp-app.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>GitHub Year in Review 2025</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
min-height: 100vh;
background: linear-gradient(135deg, #0d1117 0%, #161b22 50%, #21262d 100%);
color: #e6edf3;
padding: 20px;
}
.container { max-width: 500px; margin: 0 auto; }
.header { text-align: center; margin-bottom: 24px; }
.header h1 {
font-size: 24px;
background: linear-gradient(90deg, #58a6ff, #a371f7, #f778ba);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.search-box { display: flex; gap: 12px; margin-bottom: 24px; }
.search-box input {
flex: 1;
padding: 12px 16px;
border-radius: 8px;
border: 1px solid #30363d;
background: #0d1117;
color: #e6edf3;
font-size: 16px;
}
.search-box button {
padding: 12px 24px;
border-radius: 8px;
border: none;
background: linear-gradient(135deg, #238636, #2ea043);
color: white;
font-weight: 600;
cursor: pointer;
}
.profile-card, .verdict-card {
background: #161b22;
border: 1px solid #30363d;
border-radius: 12px;
padding: 24px;
margin-bottom: 16px;
display: none;
}
.profile-card.visible, .verdict-card.visible {
display: block;
animation: fadeIn 0.5s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.profile-header { display: flex; align-items: center; gap: 16px; margin-bottom: 20px; }
.avatar { width: 80px; height: 80px; border-radius: 50%; }
.stats-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.stat-item { background: #0d1117; border-radius: 8px; padding: 12px; text-align: center; }
.stat-value { font-size: 24px; font-weight: 700; color: #58a6ff; }
.stat-label { font-size: 11px; color: #8b949e; text-transform: uppercase; }
.verdict-card.roast { border-left: 4px solid #f85149; }
.verdict-card.compliment { border-left: 4px solid #3fb950; }
.verdict-header { font-weight: 600; text-transform: uppercase; margin-bottom: 12px; }
.roast .verdict-header { color: #f85149; }
.compliment .verdict-header { color: #3fb950; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>✨ GitHub Year in Review 2025</h1>
<p style="color: #8b949e; margin-top: 8px;">Get your stats, a roast, and a compliment</p>
</div>
<div class="search-box">
<input type="text" id="username-input" placeholder="Enter GitHub username" />
<button id="review-btn">Review</button>
</div>
<div class="profile-card" id="profile-card">
<div class="profile-header">
<img class="avatar" id="avatar" src="" alt="Avatar" />
<div>
<h2 id="name">Name</h2>
<div style="color: #8b949e;" id="profile-username">@username</div>
</div>
</div>
<div class="stats-grid">
<div class="stat-item">
<div class="stat-value" id="contributions">0</div>
<div class="stat-label">Contributions</div>
</div>
<div class="stat-item">
<div class="stat-value" id="repos-created">0</div>
<div class="stat-label">New Repos</div>
</div>
<div class="stat-item">
<div class="stat-value" id="commits">0</div>
<div class="stat-label">Commits</div>
</div>
</div>
</div>
<div class="verdict-card roast" id="roast-card">
<div class="verdict-header">🔥 The Roast</div>
<div id="roast-text"></div>
</div>
<div class="verdict-card compliment" id="compliment-card">
<div class="verdict-header">💚 The Compliment</div>
<div id="compliment-text"></div>
</div>
</div>
<script type="module" src="/src/mcp-app.ts"></script>
</body>
</html>
Create src/mcp-app.ts:
import { App, PostMessageTransport } from "@modelcontextprotocol/ext-apps";
const usernameInput = document.getElementById("username-input") as HTMLInputElement;
const reviewBtn = document.getElementById("review-btn") as HTMLButtonElement;
const profileCard = document.getElementById("profile-card")!;
const roastCard = document.getElementById("roast-card")!;
const complimentCard = document.getElementById("compliment-card")!;
const app = new App({ name: "GitHub Year in Review 2025", version: "1.0.0" });
function updateUI(review: any) {
(document.getElementById("avatar") as HTMLImageElement).src = review.avatarUrl;
document.getElementById("name")!.textContent = review.name || review.username;
document.getElementById("profile-username")!.textContent = `@${review.username}`;
document.getElementById("contributions")!.textContent = review.stats.totalContributions;
document.getElementById("repos-created")!.textContent = review.stats.reposCreated;
document.getElementById("commits")!.textContent = review.stats.totalCommits;
document.getElementById("roast-text")!.textContent = review.roast;
document.getElementById("compliment-text")!.textContent = review.compliment;
profileCard.classList.add("visible");
setTimeout(() => roastCard.classList.add("visible"), 200);
setTimeout(() => complimentCard.classList.add("visible"), 400);
}
reviewBtn.addEventListener("click", async () => {
const username = usernameInput.value.trim();
if (!username) return;
reviewBtn.disabled = true;
reviewBtn.textContent = "Loading...";
profileCard.classList.remove("visible");
roastCard.classList.remove("visible");
complimentCard.classList.remove("visible");
try {
const result = await app.callServerTool({
name: "get-year-in-review",
arguments: { username },
});
updateUI(result.structuredContent);
} catch (error) {
console.error(error);
}
reviewBtn.disabled = false;
reviewBtn.textContent = "Review";
});
usernameInput.addEventListener("keypress", (e) => {
if (e.key === "Enter") reviewBtn.click();
});
app.connect(new PostMessageTransport(window.parent));
Run It
Build the UI:
npm run build
Start the server (get a token from https://github.com/settings/tokens . I didn't add any scopes to mine.):
GITHUB_TOKEN=your_token_here npm run serve
Additional Reading and Resources
- MCP Apps Blog Post - The announcement explaining why this matters
- Official Quickstart - If you want the basics first
- SEP-1865 - The full spec if you're into that
- My repo

Top comments (0)