DEV Community

Cover image for How I Built an MCP App to Roast My GitHub Year in Review
Rizèl Scarlett
Rizèl Scarlett

Posted on

How I Built an MCP App to Roast My GitHub Year in Review

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:

Resourcegithub://user/{username}/2025-review

This exposes the cached review data so other tools or LLMs can read it.

Toolget-year-in-review

This does the heavy lifting—fetches your GitHub data, crunches the stats, and generates your roast and compliment.

Promptyear-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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Create tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "dist"
  },
  "include": ["*.ts", "src/**/*.ts"]
}
Enter fullscreen mode Exit fullscreen mode

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,
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Update your package.json scripts:

{
  "type": "module",
  "scripts": {
    "build": "INPUT=mcp-app.html vite build",
    "serve": "npx tsx server.ts"
  }
}
Enter fullscreen mode Exit fullscreen mode

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>();
Enter fullscreen mode Exit fullscreen mode

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),
  };
}
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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 }],
    };
  }
);
Enter fullscreen mode Exit fullscreen mode

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");
});
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

Run It

Build the UI:

npm run build
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Additional Reading and Resources

Top comments (0)