<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ Shola Jegede - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ Shola Jegede - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Sun, 24 May 2026 22:23:53 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/author/sholajegede/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How to Build and Deploy Tetris Inside ChatGPT: A Complete Guide to the Vercel ChatGPT Apps SDK with TypeScript, Convex, and Kinde OAuth ]]>
                </title>
                <description>
                    <![CDATA[ Imagine playing a fully functional game of Tetris without leaving your ChatGPT conversation: rotating pieces, clearing lines, competing on leaderboards – all within the chat interface you already use  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-and-deploy-tetris-inside-chatgpt/</link>
                <guid isPermaLink="false">69a5cfdbe8e1f9df72cd846f</guid>
                
                    <category>
                        <![CDATA[ handbook ]]>
                    </category>
                
                    <category>
                        <![CDATA[ chatgpt ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ TypeScript ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Shola Jegede ]]>
                </dc:creator>
                <pubDate>Sat, 17 Jan 2026 09:00:00 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5fc16e412cae9c5b190b6cdd/d0bb371a-34b3-4f8e-9bf1-3579f3e4b639.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Imagine playing a fully functional game of Tetris without leaving your ChatGPT conversation: rotating pieces, clearing lines, competing on leaderboards – all within the chat interface you already use every day.</p>
<p>With the Vercel ChatGPT Apps SDK and the Model Context Protocol (MCP), you can embed rich, interactive applications directly into ChatGPT.</p>
<p>In this tutorial, you'll build a production-ready Tetris game that lives inside ChatGPT, complete with user authentication, real-time leaderboards, and a replay system.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-youll-build">What You'll Build</a></p>
</li>
<li><p><a href="#heading-why-this-matters">Why This Matters</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-tech-stack-overview">Tech Stack Overview</a></p>
</li>
<li><p><a href="#heading-understanding-the-architecture">Understanding the Architecture</a></p>
</li>
<li><p><a href="#heading-key-concepts">Key Concepts</a></p>
</li>
<li><p><a href="#heading-project-setup-amp-initial-configuration">Project Setup &amp; Initial Configuration</a></p>
</li>
<li><p><a href="#heading-setting-up-the-convex-backend">Setting Up the Convex Backend</a></p>
</li>
<li><p><a href="#heading-building-the-tetris-game-engine">Building the Tetris Game Engine</a></p>
</li>
<li><p><a href="#heading-implementing-kinde-oauth-authentication">Implementing Kinde OAuth Authentication</a></p>
</li>
<li><p><a href="#heading-building-the-mcp-integration">Building the MCP Integration</a></p>
</li>
<li><p><a href="#heading-building-the-supporting-features">Building the Supporting Features</a></p>
</li>
<li><p><a href="#heading-deploying-to-vercel">Deploying to Vercel</a></p>
</li>
<li><p><a href="#heading-registering-with-chatgpt">Registering with ChatGPT</a></p>
</li>
<li><p><a href="#heading-finishing-up">Finishing Up</a></p>
</li>
<li><p><a href="#heading-troubleshooting">Troubleshooting</a></p>
</li>
<li><p><a href="#heading-final-data-flow">Final Data Flow</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-youll-build">What You'll Build</h2>
<p>By the end of this tutorial, you'll have a full-stack application with:</p>
<ol>
<li><p><strong>Core gameplay:</strong> A fully playable Tetris game with smooth animations, keyboard and touch controls, real-time scoring, and level progression.</p>
</li>
<li><p><strong>User Features:</strong> OAuth authentication via Kinde, persistent user profiles, public and private game modes, and a replay system that records every move.</p>
</li>
<li><p><strong>Social and competitive feel:</strong> A global leaderboard, replay viewer, and ChatGPT integration that lets you start games, check scores, and review replays through natural conversation.</p>
</li>
<li><p><strong>Technical architecture:</strong> Next.js 16 frontend with React 19 and Shadcn UI, Convex real-time database, MCP integration for ChatGPT tool registration, and production deployment on Vercel.</p>
</li>
</ol>
<p>The user experience looks like this: say "Start a new Tetris game" in ChatGPT, an embedded game widget appears, and you play using arrow keys or on-screen controls. When the game ends, ChatGPT updates your score. Then you can ask "Show me the top 10 players" and the leaderboard appears — all in a single conversational flow.</p>
<h2 id="heading-why-this-matters">Why This Matters</h2>
<p>The ChatGPT Apps SDK represents a fundamental shift in how we think about AI applications. Instead of building separate interfaces or forcing users to navigate between ChatGPT and your app, you bring your application into the conversation.</p>
<p>This means zero learning curve (users already know ChatGPT), contextual AI intelligence, reduced friction (no app downloads or extra accounts), and access to 800M weekly active users.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<h3 id="heading-required-skills">Required skills</h3>
<p>To follow along, you'll need JavaScript/TypeScript fundamentals (ES6+, async/await), React basics (components, hooks, props), basic Next.js familiarity, and command-line comfort.</p>
<h3 id="heading-required-accounts-and-tools">Required accounts and tools</h3>
<ul>
<li><p>Node.js 20+ and pnpm 10+</p>
</li>
<li><p>ChatGPT Plus subscription ($20/month as of January 2026)</p>
</li>
<li><p>Vercel account (free tier works)</p>
</li>
<li><p>Convex account (free tier: 1GB storage, 1M function calls/month)</p>
</li>
<li><p>Kinde account (free tier: 10,500 monthly active users)</p>
</li>
</ul>
<h2 id="heading-tech-stack-overview">Tech Stack Overview</h2>
<p>Let's quickly go over the tools we'll be using, and why we'll be using them, so you're familiar with our tech stack.</p>
<h3 id="heading-frontend-tools">Frontend Tools</h3>
<p><strong>Next.js 16 + React 19 + Shadcn UI</strong>: App Router, serverless API routes, optimized builds via Turbopack, and React 19's improved rendering.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771370133925/6e9aa2d5-4003-4d6a-8a10-52fa63fabfee.png" alt="Tetris Gameplay in ChatGPT" style="display:block;margin:0 auto" width="3422" height="1820" loading="lazy">

<h3 id="heading-backend-tools">Backend Tools</h3>
<p><strong>Convex</strong>: Replaces traditional databases and ORMs with a TypeScript-native real-time database. Here's why it matters:</p>
<pre><code class="language-typescript">// Traditional approach:
const game = await db.query("SELECT * FROM games WHERE id = ?", [gameId]);
game.score += 100;
await db.query("UPDATE games SET score = ? WHERE id = ?", [game.score, gameId]);
// Manually notify clients via WebSockets...

// Convex approach:
export const updateScore = mutation({
  args: { gameId: v.id("games"), points: v.number() },
  handler: async (ctx, args) =&gt; {
    await ctx.db.patch(args.gameId, { score: args.score + args.points });
    // All subscribed clients automatically receive the update
  }
});
</code></pre>
<p><strong>Authentication – Kinde OAuth 2.0</strong>: Handles secure JWT token generation, user profile management, and multiple providers. Simpler than Auth0, with a more generous free tier (10,500 MAU vs. 7,500).</p>
<p><strong>Deployment – Vercel</strong>: Zero-config Next.js deployment, automatic HTTPS, global CDN, and preview deployments for every PR.</p>
<p><strong>ChatGPT Integration – MCP (Model Context Protocol)</strong>: The bridge between ChatGPT and your application. Defines tools ChatGPT can call, resources it can read, and suggested phrases for users. The <code>mcp-handler</code> npm package handles protocol negotiation, message parsing, OAuth token extraction, and CORS headers:</p>
<pre><code class="language-typescript">import { createMCPHandler } from 'mcp-handler';

export const { GET, POST } = createMCPHandler({
  name: "Tetris Game Server",
  version: "1.0.0",
  setupServer: async (server) =&gt; {
    // Register all your tools here
  }
});
</code></pre>
<h2 id="heading-understanding-the-architecture">Understanding the Architecture</h2>
<p>Before writing a line of code, you need a mental model of how all the pieces fit together. This section moves from high-level system design down to specific component interactions.</p>
<h3 id="heading-the-vercel-chatgpt-app-template-foundation">The Vercel ChatGPT App Template Foundation</h3>
<p>The Vercel ChatGPT Apps template solves several hard problems out of the box: a pre-configured Next.js 16 setup with Turbopack, an integrated MCP protocol handler, OAuth discovery endpoints, CORS configuration for ChatGPT's iframe security model, and a widget rendering system.</p>
<p>The most important configuration file is <code>next.config.ts</code>. ChatGPT renders your app in an iframe from a different origin, so without permissive CORS headers, browsers block the cross-origin requests your game needs:</p>
<pre><code class="language-typescript">// next.config.ts
const nextConfig: NextConfig = {
  async headers() {
    return [
      {
        source: "/:path*",
        headers: [
          { key: "Access-Control-Allow-Origin", value: "*" },
          { key: "Access-Control-Allow-Methods", value: "GET,POST,PUT,DELETE,OPTIONS" },
          { key: "Access-Control-Allow-Headers", value: "*" },
        ],
      },
    ];
  },
};
</code></pre>
<p>The template also provides a <code>baseURL</code> helper that's critical for OAuth redirects:</p>
<pre><code class="language-typescript">// lib/baseURL.ts
export const baseURL =
  process.env.NODE_ENV === "development"
    ? "http://localhost:3000"
    : "https://" +
      (process.env.VERCEL_ENV === "production"
        ? process.env.VERCEL_PROJECT_PRODUCTION_URL
        : process.env.VERCEL_BRANCH_URL || process.env.VERCEL_URL);
</code></pre>
<p>This ensures OAuth callbacks work in local development, preview deployments, and production without hardcoding URLs.</p>
<h3 id="heading-three-tier-architecture">Three-Tier Architecture</h3>
<p>Our Tetris application uses a classic three-tier architecture with modern serverless components:</p>
<pre><code class="language-markdown">┌──────────────────────────────────────────────────────┐
│                 TIER 1: PRESENTATION                 │
│  Chat Interface  ◄────────  Widget (iframe)          │
│  (natural language)         (React events)           │
└────────────────────────┬─────────────────────────────┘
                         │ MCP (JSON-RPC) / HTTP
┌────────────────────────▼─────────────────────────────┐
│                TIER 2: APPLICATION                   │
│  MCP Route     │    API Routes    │   React Pages    │
│  /app/mcp      │    /app/api/*    │   /app/tetris/*  │
└────────────────────────┬─────────────────────────────┘
                         │ Convex SDK
┌────────────────────────▼─────────────────────────────┐
│                   TIER 3: DATA                       │
│  Users │ Games │ Replays │ Leaderboard               │
│  Convex Functions (mutations &amp; queries)              │
└──────────────────────────────────────────────────────┘
</code></pre>
<p>Authentication flows horizontally across all tiers. The key insight: ChatGPT handles steps 1–5 of the OAuth flow automatically (redirect to Kinde, user authenticates, redirect back with code, exchange for token). Your Next.js app only handles steps 6–10: validation and user management.</p>
<h3 id="heading-component-relationships-and-data-flow">Component Relationships and Data Flow</h3>
<p>Here's the file structure you'll build:</p>
<pre><code class="language-markdown">/app
  /mcp/.well-known/oauth-protected-resource/route.ts
  /mcp/route.ts                    # MCP server + tool registration
  /api/create-game/route.ts        # HTTP endpoint for game creation
  /lib/kinde.ts                    # Kinde token validation
  /lib/mcpAuth.ts                  # MCP-specific auth helpers
  /tetris/play/page.tsx            # Main game interface
  /tetris/leaderboard/page.tsx     # Top scores table
  /tetris/replays/page.tsx         # Replay browser

/components/tetris/
  GameBoard.tsx                    # Core game logic + rendering
  Leaderboard.tsx                  # Leaderboard table component
  ReplayViewer.tsx                 # Replay playback component

/convex/
  schema.ts                        # Database table definitions
  games.ts                         # Game CRUD operations
  users.ts                         # User management
  replays.ts                       # Replay storage + retrieval
  leaderboards.ts                  # Top scores queries
</code></pre>
<p>The most important relationship is <code>GameBoard.tsx</code> to Convex. When a game starts, the MCP route creates a game in Convex and returns a widget URI. ChatGPT renders <code>GameBoard.tsx</code> in an iframe, which loads game state from Convex via <code>useQuery</code>.</p>
<p>The user plays locally (no round-trips for each move), actions are recorded in a ref, and when the game ends, everything syncs to Convex in a single mutation.</p>
<p>Leaderboard real-time updates demonstrate why Convex shines:</p>
<pre><code class="language-typescript">// components/tetris/Leaderboard.tsx
export default function Leaderboard() {
  // Re-runs automatically whenever leaderboard data changes
  const entries = useQuery(api.leaderboards.listTop, { limit: 20 }) || [];
  
  const userIds = entries.map((e: any) =&gt; e.userId).filter(Boolean);
  const users = useQuery(
    api.users.getMultipleById, 
    userIds.length &gt; 0 ? { userIds } : "skip"
  );

  const userMap = new Map();
  if (users) {
    users.forEach((user: any) =&gt; {
      if (user) userMap.set(user._id, user);
    });
  }

  return (
    &lt;div className="max-w-2xl mx-auto p-4"&gt;
      &lt;h2 className="text-2xl font-bold mb-4"&gt;Leaderboard&lt;/h2&gt;
      &lt;ol className="list-decimal pl-6 space-y-2"&gt;
        {entries.map((e: any, idx: number) =&gt; {
          const user = userMap.get(e.userId);
          const displayName = user 
            ? (user.displayName || `\({user.firstName || ''} \){user.lastName || ''}`.trim() || user.email)
            : 'Anonymous';
          
          return (
            &lt;li key={e._id} className="flex justify-between"&gt;
              &lt;div&gt;{displayName}&lt;/div&gt;
              &lt;div&gt;{e.score}&lt;/div&gt;
            &lt;/li&gt;
          );
        })}
      &lt;/ol&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>When someone finishes a game elsewhere, their <code>GameBoard</code> calls <code>finishGame</code>, Convex updates the leaderboards table, and your <code>Leaderboard</code> component re-renders automatically with new data. No polling, no WebSockets, no manual refresh code.</p>
<h2 id="heading-key-concepts">Key Concepts</h2>
<h4 id="heading-mcp-model-context-protocol"><strong>MCP (Model Context Protocol)</strong></h4>
<p>This is a JSON-RPC protocol that defines how AI assistants interact with external applications. It has three primitives:</p>
<ul>
<li><p><strong>Tools</strong>: Functions the AI can call (for example, <code>start_game</code>, <code>finish_game</code>)</p>
</li>
<li><p><strong>Resources</strong>: Data the AI can read (for example, leaderboard entries as widgets)</p>
</li>
<li><p><strong>Prompts</strong>: Suggested phrases for users</p>
</li>
</ul>
<p>When you register your app, ChatGPT fetches your MCP manifest and discovers available tools. When a user types "start a game," ChatGPT's language model matches the intent to <code>start_game</code> and calls it automatically.</p>
<p>The complete tool execution lifecycle is:</p>
<ol>
<li><p>The user types natural language</p>
</li>
<li><p>ChatGPT calls your MCP endpoint via POST with the tool name and arguments</p>
</li>
<li><p>Your handler validates OAuth, creates a game in Convex, and returns the widget HTML</p>
</li>
<li><p>ChatGPT renders the widget in the chat interface</p>
</li>
<li><p>The user plays, and the widget calls <code>finish_game</code> when done</p>
</li>
<li><p>ChatGPT displays the results in chat</p>
</li>
</ol>
<h3 id="heading-widgets"><strong>Widgets</strong></h3>
<p>These are full HTML documents rendered in sandboxed iframes. Your Next.js pages become widgets via the <code>getAppsSdkCompatibleHtml</code> helper. The <code>text/html+skybridge</code> MIME type tells ChatGPT to render the content as an interactive widget rather than plain text.</p>
<h3 id="heading-tool-registration"><strong>Tool registration</strong></h3>
<p>This uses Zod schemas for input validation and security schemes to control authentication requirements.</p>
<p><code>registerTool</code> takes three things: the tool name, a configuration object describing the tool's inputs, security requirements, and widget metadata, and an async handler function that runs when ChatGPT calls the tool.</p>
<p>The <code>inputSchema</code> uses Zod to define and validate what arguments ChatGPT can pass in. The <code>securitySchemes</code> array controls whether authentication is required. The <code>_meta</code> field tells ChatGPT to render the tool's response as an interactive widget rather than plain text.</p>
<pre><code class="language-typescript">s.registerTool(
    "start_game",
    {
      securitySchemes: [
        { type: "noauth" }, // Allow anonymous play
        { type: "oauth2", scopes: ["profile"] },
      ],
      title: "Start Game",
      description:
        "Start a new Tetris game (creates a game record and opens the play widget). User will be associated if authenticated.",
      inputSchema: {
        public: z
          .boolean()
          .optional()
          .describe("Whether the game is public for spectating"),
        seed: z
          .number()
          .optional()
          .describe("Optional seed for deterministic play"),
      },
      _meta: widgetMeta(playWidget),
    },
  async (args: any, context: any) =&gt; { /* handler */ }
);
</code></pre>
<p>Multiple security schemes mean OR logic and users can play anonymously OR authenticated. A single scheme makes authentication required.</p>
<h3 id="heading-a-complete-request-flow">A Complete Request Flow</h3>
<p>To make the architecture concrete, here's a full trace when an authenticated user starts a public game:</p>
<ol>
<li><p>The user says "Start a public Tetris game"</p>
</li>
<li><p>ChatGPT parses intent, checks for existing OAuth token</p>
</li>
<li><p>ChatGPT POSTs to <code>/mcp</code> with <code>Authorization: Bearer &lt;token&gt;</code> and <code>{ "method": "tools/call", "params": { "name": "start_game", "arguments": { "public": true } } }</code></p>
</li>
<li><p>The Next.js MCP route extracts and validates the token via Kinde JWKS</p>
</li>
<li><p>Next.js calls <code>api.users.upsertLinkedAccount</code> in Convex, receives a <code>userId</code></p>
</li>
<li><p>Next.js calls <code>api.games.createGame</code> in Convex with the <code>userId</code>, receives a <code>gameId</code></p>
</li>
<li><p>Next.js renders widget HTML for <code>/tetris/play?gameId=&lt;gameId&gt;</code></p>
</li>
<li><p>The Widget HTML is returned to ChatGPT in MCP response</p>
</li>
<li><p>ChatGPT renders the widget and <code>GameBoard.tsx</code> mounts and fetches game state</p>
</li>
<li><p>The user plays, the score updates locally, and actions are recorded in a ref</p>
</li>
<li><p>The game ends. <code>GameBoard</code> calls <code>api.games.finishGame</code> which atomically creates replay, updates leaderboard, updates user stats.</p>
</li>
<li><p>Convex reactivity pushes leaderboard updates to all subscribed clients</p>
</li>
<li><p><code>GameBoard</code> calls <code>useSendMessage</code> to post results back to the chat thread</p>
</li>
</ol>
<h2 id="heading-project-setup-amp-initial-configuration">Project Setup &amp; Initial Configuration</h2>
<p>By the end of this section, you'll have a running Next.js app, a connected Convex database, Kinde OAuth configured, and all environment variables in place.</p>
<h3 id="heading-install-required-tools">Install Required Tools</h3>
<p>Verify your Node.js version first:</p>
<pre><code class="language-bash">node --version   # Should be v24.x or higher
pnpm --version   # Should be v10.x or higher
</code></pre>
<p>If pnpm isn't installed:</p>
<pre><code class="language-bash">npm install -g pnpm
</code></pre>
<h3 id="heading-clone-the-vercel-chatgpt-apps-template">Clone the Vercel ChatGPT Apps Template</h3>
<pre><code class="language-bash">pnpm create next-app@latest tetris-chatgpt-app \
  --example https://github.com/vercel-labs/chatgpt-apps-sdk-nextjs-starter

cd tetris-chatgpt-app
pnpm install
</code></pre>
<h3 id="heading-install-project-dependencies">Install Project Dependencies</h3>
<p>Add all packages needed for the complete application:</p>
<pre><code class="language-bash"># Real-time database
pnpm add convex@^1.29.3

# MCP handler
pnpm add mcp-handler@^1.0.2

# Kinde OAuth token validation
pnpm add jose@^6.1.3

# Radix UI primitives (used by Shadcn)
pnpm add @radix-ui/react-dialog@^1.1.15 \
         @radix-ui/react-label@^2.1.8 \
         @radix-ui/react-select@^2.2.6 \
         @radix-ui/react-slot@^1.2.4 \
         @radix-ui/react-tabs@^1.1.13

# Utilities and UI
pnpm add class-variance-authority@^0.7.1 \
         clsx@^2.1.1 \
         lucide-react@^0.555.0 \
         sonner@^2.0.7 \
         tailwind-merge@^3.4.0 \
         zod@3.24.2 \
         next-themes@^0.4.6 \
         @modelcontextprotocol/sdk@^1.20.0

# Dev dependencies
pnpm add -D @tailwindcss/postcss@^4 tailwindcss@^4 tw-animate-css@^1.4.0
</code></pre>
<h3 id="heading-set-up-convex">Set Up Convex</h3>
<pre><code class="language-bash">pnpm add -g convex
pnpm convex dev
</code></pre>
<p>The interactive prompt will ask you to create a new project. After setup, Convex creates a <code>convex/</code> directory, a <code>.env.local</code> file with your deployment URL, and starts watching for schema changes. Keep this terminal running throughout development.</p>
<p>Add the Convex React provider. Create <code>app/provider.tsx</code>:</p>
<pre><code class="language-typescript">"use client";

import { ReactNode } from "react";
import { ConvexProvider, ConvexReactClient } from "convex/react";

const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);

export function ConvexClientProvider({ children }: { children: ReactNode }) {
  return &lt;ConvexProvider client={convex}&gt;{children}&lt;/ConvexProvider&gt;;
}
</code></pre>
<p>Then wrap your app in <code>app/layout.tsx</code>:</p>
<pre><code class="language-typescript">import { ConvexClientProvider } from "./providers";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    &lt;html lang="en"&gt;
      &lt;body
        className={`\({geistSans.variable} \){geistMono.variable} antialiased`}
      &gt;
        &lt;ConvexClientProvider&gt;{children}&lt;/ConvexClientProvider&gt;
      &lt;/body&gt;
    &lt;/html&gt;
  );
}
</code></pre>
<h3 id="heading-set-up-kinde-oauth">Set Up Kinde OAuth</h3>
<p>Go to <a href="https://kinde.com?utm_source=fcc&amp;utm_medium=content&amp;utm_campaign=shola&amp;campaignid=chatgptapp&amp;network=&amp;adgroup=&amp;keyword=&amp;matchtype=&amp;creative=3&amp;device=&amp;adposition=">kinde.com</a>, create an account, then create a new back-end web application named "Tetris ChatGPT App."</p>
<p>In your application's settings, add these allowed callback URLs:</p>
<pre><code class="language-markdown">https://localhost:3000/api/auth/callback
https://chatgpt.com/connector_platform_oauth_redirect
</code></pre>
<p>And these allowed logout redirect URLs:</p>
<pre><code class="language-markdown">https://tetris-chatgpt-app.vercel.app
https://chatgpt.com
</code></pre>
<p>Copy your Domain, Client ID, and Client Secret from the application details page.</p>
<p>Now create <code>app/lib/kinde.ts</code>:</p>
<pre><code class="language-typescript">import { createRemoteJWKSet, jwtVerify } from 'jose';

const KINDE_ISSUER = process.env.KINDE_ISSUER!;
const MCP_AUDIENCE = process.env.MCP_AUDIENCE!;

let cachedJWKS: ReturnType&lt;typeof createRemoteJWKSet&gt; | null = null;

async function getJWKS() {
  if (!cachedJWKS) {
    cachedJWKS = createRemoteJWKSet(
      new URL(`${KINDE_ISSUER}/.well-known/jwks.json`)
    );
  }
  return cachedJWKS;
}

export async function validateKindeToken(token: string) {
  if (!token) throw new Error('No token provided');

  const JWKS = await getJWKS();
  const { payload } = await jwtVerify(token, JWKS, {
    issuer: KINDE_ISSUER,
    audience: MCP_AUDIENCE,
  });

  return payload as {
    sub: string;
    email?: string;
    given_name?: string;
    family_name?: string;
    picture?: string;
    exp: number;
    iat: number;
  };
}

export async function getKindeUserProfile(token: string) {
  const payload = await validateKindeToken(token);
  return {
    id: payload.sub,
    email: payload.email,
    name: [payload.given_name, payload.family_name].filter(Boolean).join(' ') || 'Anonymous',
    picture: payload.picture,
  };
}
</code></pre>
<p>Create the OAuth discovery endpoint at <code>app/mcp/.well-known/oauth-protected-resource/route.ts</code>:</p>
<pre><code class="language-typescript">import { NextResponse } from 'next/server';

export async function GET() {
  return NextResponse.json({
    resource: process.env.MCP_RESOURCE!,
    authorization_servers: [process.env.KINDE_ISSUER!],
    scopes_supported: ['openid', 'profile', 'email'],
    bearer_methods_supported: ['header'],
  });
}
</code></pre>
<p>This endpoint tells ChatGPT where to send users to authenticate. Without it, ChatGPT can't discover your OAuth configuration.</p>
<p>Create <code>app/lib/utils.ts</code>:</p>
<pre><code class="language-typescript">import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}
</code></pre>
<h3 id="heading-environment-variables">Environment Variables</h3>
<p>Your complete <code>.env.local</code> should look like this:</p>
<pre><code class="language-bash"># Convex (auto-generated by pnpm convex dev)
CONVEX_DEPLOYMENT=dev:your-deployment-name
NEXT_PUBLIC_CONVEX_URL=https://your-deployment.convex.cloud

# Kinde OAuth
KINDE_ISSUER=https://yourcompany.kinde.com
KINDE_CLIENT_ID=your-client-id
KINDE_CLIENT_SECRET=your-client-secret

# MCP settings (update after deploying to Vercel)
MCP_AUDIENCE=http://localhost:3000/mcp
MCP_RESOURCE=http://localhost:3000
MCP_DOC_URL=http://localhost:3000/mcp-docs
</code></pre>
<p>Make sure <code>.env.local</code> is in your <code>.gitignore</code> and it should be by default in the template.</p>
<h3 id="heading-verify-everything-works">Verify Everything Works</h3>
<p>Open three terminals:</p>
<pre><code class="language-bash"># Terminal 1
pnpm convex dev

# Terminal 2
pnpm dev

# Terminal 3 — test the OAuth discovery endpoint
curl http://localhost:3000/mcp/.well-known/oauth-protected-resource
# Expected: { "resource": "...", "authorization_servers": [...], ... }
</code></pre>
<p>Open <code>http://localhost:3000</code> in your browser. If it loads without console errors and the OAuth endpoint returns JSON, your setup is complete.</p>
<p><strong>Common issues:</strong></p>
<ul>
<li><p><code>"Cannot find module 'convex/react'"</code> – run <code>pnpm install</code> and restart the dev server</p>
</li>
<li><p><code>"NEXT_PUBLIC_CONVEX_URL is not defined"</code> – run <code>pnpm convex dev</code> to regenerate <code>.env.local</code></p>
</li>
<li><p><code>"Failed to verify token signature"</code> – ensure <code>KINDE_ISSUER</code> has no trailing slash</p>
</li>
</ul>
<h3 id="heading-set-up-vercel">Set Up Vercel</h3>
<p>Install the CLI and link your project so deployment is one command away later:</p>
<pre><code class="language-bash">pnpm add -g vercel
vercel login
vercel link
</code></pre>
<p>Add your environment variables to Vercel now:</p>
<pre><code class="language-bash">vercel env add NEXT_PUBLIC_CONVEX_URL
vercel env add KINDE_ISSUER
vercel env add KINDE_CLIENT_ID
vercel env add KINDE_CLIENT_SECRET
</code></pre>
<p>You'll update the MCP-specific variables after your first deployment once you have a production URL.</p>
<h2 id="heading-setting-up-the-convex-backend">Setting Up the Convex Backend</h2>
<p>This section builds the complete data layer: schema, mutations, queries, and real-time reactivity. By the end, you'll have a fully functional backend that automatically pushes updates to every connected client the moment data changes — no polling, no manual refresh logic.</p>
<h3 id="heading-database-schema-design">Database Schema Design</h3>
<p>The schema is the contract for everything your app stores. Open <code>convex/schema.ts</code> and replace its contents with the full schema below. Take a moment to read through the table definitions before pasting — understanding what each table stores and why will make the mutation code much easier to follow.</p>
<p>Open <code>convex/schema.ts</code> and replace its contents with the full schema:</p>
<pre><code class="language-typescript">import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({
  users: defineTable({
    email: v.string(),
    displayName: v.optional(v.string()),
    firstName: v.optional(v.string()),
    lastName: v.optional(v.string()),
    imageUrl: v.optional(v.string()),
    imageStorageId: v.optional(v.id("_storage")),
    updatedAt: v.number(),
  }).index("by_email", ["email"]),

  linkedAccounts: defineTable({
    provider: v.string(),
    subject: v.string(),
    userId: v.id("users"),
    profile: v.optional(v.object({})),
    updatedAt: v.number(),
  }).index("by_provider_subject", ["provider", "subject"]).index("by_user", ["userId"]),

  games: defineTable({
    userId: v.optional(v.id("users")),
    status: v.union(
      v.literal("active"),
      v.literal("paused"),
      v.literal("finished"),
      v.literal("abandoned")
    ),
    score: v.number(),
    level: v.number(),
    linesCleared: v.number(),
    board: v.array(v.number()),
    currentPiece: v.optional(
      v.object({ type: v.string(), rotation: v.number(), x: v.number(), y: v.number() })
    ),
    nextQueue: v.optional(v.array(v.string())),
    holdPiece: v.optional(v.string()),
    seed: v.optional(v.number()),
    replayId: v.optional(v.id("replays")),
    public: v.optional(v.boolean()),
    updatedAt: v.number(),
  })
    .index("by_user", ["userId"])
    .index("by_status", ["status"])
    .index("by_score", ["score"]),

  replays: defineTable({
    gameId: v.id("games"),
    userId: v.optional(v.id("users")),
    actions: v.array(v.object({ t: v.number(), a: v.string(), p: v.optional(v.object({})) })),
    durationMs: v.number(),
  }).index("by_game", ["gameId"]),

  leaderboards: defineTable({
    userId: v.id("users"),
    score: v.number(),
    level: v.number(),
    linesCleared: v.number(),
  }).index("by_score", ["score"]),
});
</code></pre>
<p>A few design decisions worth calling out:</p>
<p><code>linkedAccounts</code> <strong>is a separate table</strong>, so one user can authenticate via multiple OAuth providers without duplicating their profile. The <code>provider</code> + <code>subject</code> pair (for example, <code>"kinde"</code> + <code>"kinde|2151678548"</code>) uniquely identifies an OAuth identity. The <code>userId</code> field is a foreign key pointing to the single <code>users</code> row that represents the human behind potentially many auth accounts.</p>
<p><strong>The</strong> <code>replays.actions</code> <strong>array uses a compact format</strong> — <code>{ t, a }</code> stands for timestamp and action code — so an entire game fits in roughly 50KB of JSON rather than megabytes of full board snapshots taken at every tick.</p>
<p><strong>Indexes are not optional.</strong> Without <code>by_email</code> on <code>users</code>, finding a returning user requires a full table scan that grows linearly with your user count. With the index, lookups are O(log n) regardless of scale. Every table that will be queried by a specific field needs an index on that field.</p>
<h3 id="heading-user-management">User Management</h3>
<p>Create <code>convex/users.ts</code>. This file is the backbone of your identity system — it handles looking users up, creating new ones, and most importantly, linking OAuth provider identities to your own user records.</p>
<pre><code class="language-typescript">import { query, mutation } from "./_generated/server";
import { v } from "convex/values";

export const getByEmail = query({
  args: { email: v.string() },
  handler: async (ctx, { email }) =&gt; {
    return await ctx.db
      .query("users")
      .withIndex("by_email", (q) =&gt; q.eq("email", email))
      .first();
  },
});

export const getById = query({
  args: { userId: v.id("users") },
  handler: async (ctx, { userId }) =&gt; {
    return await ctx.db.get(userId);
  },
});

export const getMultipleById = query({
  args: { userIds: v.array(v.id("users")) },
  handler: async (ctx, { userIds }) =&gt; {
    const users = await Promise.all(
      userIds.map(id =&gt; ctx.db.get(id))
    );
    return users;
  },
});

export const createOrUpdate = mutation({
  args: {
    email: v.string(),
    displayName: v.optional(v.string()),
    firstName: v.optional(v.string()),
    lastName: v.optional(v.string()),
    imageUrl: v.optional(v.string()),
  },
  handler: async (ctx, args) =&gt; {
    const now = Date.now();

    const existingUser = await ctx.db
      .query("users")
      .withIndex("by_email", (q) =&gt; q.eq("email", args.email))
      .first();

    if (existingUser) {
      // Update existing user — only overwrite fields that are actually provided,
      // so a partial update won't clear data you didn't intend to touch.
      return await ctx.db.patch(existingUser._id, {
        displayName: args.displayName ?? existingUser.displayName,
        firstName: args.firstName ?? existingUser.firstName,
        lastName: args.lastName ?? existingUser.lastName,
        imageUrl: args.imageUrl ?? existingUser.imageUrl,
        updatedAt: now,
      });
    }

    // First time we've seen this email — create a new user record
    return await ctx.db.insert("users", {
      email: args.email,
      displayName: args.displayName,
      firstName: args.firstName,
      lastName: args.lastName,
      imageUrl: args.imageUrl,
      updatedAt: now,
    });
  },
});

export const patchProfile = mutation({
  args: {
    userId: v.id("users"),
    displayName: v.optional(v.string()),
    firstName: v.optional(v.string()),
    lastName: v.optional(v.string()),
    imageUrl: v.optional(v.string()),
    imageStorageId: v.optional(v.id("_storage")),
  },
  handler: async (ctx, args) =&gt; {
    // Build the patch object dynamically so we only write fields that were passed in
    const patch: Record&lt;string, any&gt; = { updatedAt: Date.now() };
    if (args.displayName !== undefined) patch.displayName = args.displayName;
    if (args.firstName !== undefined) patch.firstName = args.firstName;
    if (args.lastName !== undefined) patch.lastName = args.lastName;
    if (args.imageUrl !== undefined) patch.imageUrl = args.imageUrl;
    if (args.imageStorageId !== undefined) patch.imageStorageId = args.imageStorageId;

    return await ctx.db.patch(args.userId, patch);
  },
});

export const upsertLinkedAccount = mutation({
  args: {
    provider: v.string(),
    subject: v.string(),
    profile: v.optional(v.object({})),
  },
  handler: async (ctx, { provider, subject, profile }) =&gt; {
    const now = Date.now();

    // Step 1: Check if we've seen this exact OAuth identity before
    const linked = await ctx.db
      .query("linkedAccounts")
      .withIndex("by_provider_subject", (q) =&gt; q.eq("provider", provider).eq("subject", subject))
      .first();

    if (linked) {
      // Already linked — just refresh the cached profile data and return the existing userId
      await ctx.db.patch(linked._id, { profile: profile ?? linked.profile, updatedAt: now });
      return linked.userId;
    }

    // Step 2: New OAuth identity — try to find an existing Convex user by email
    // so we don't create a duplicate account if this person signed up a different way
    let user = null;
    const email = profile &amp;&amp; (profile as any).email;
    if (email) {
      user = await ctx.db
        .query("users")
        .withIndex("by_email", (q) =&gt; q.eq("email", email))
        .first();
    }

    // Step 3: If no match by email, create a brand new user record
    if (!user) {
      const created = await ctx.db.insert("users", {
        email: email ?? `\({provider}:\){subject}`,
        displayName: profile &amp;&amp; (profile as any).name,
        imageUrl: profile &amp;&amp; (profile as any).picture,
        updatedAt: now,
      });
      user = created;
    }
    
    const userId = typeof user === "string" ? user : user._id;

    // Step 4: Record the link between this OAuth identity and the Convex user
    await ctx.db.insert("linkedAccounts", {
      provider,
      subject,
      userId: userId,
      profile: profile ?? {},
      updatedAt: now,
    });

    return userId;
  },
});
</code></pre>
<p><code>upsertLinkedAccount</code> is the most important mutation in this file — it's the OAuth entry point for your entire app. Every time a user authenticates via Kinde, you call this function and it runs through four steps:</p>
<ol>
<li><p>Look up the OAuth identity (<code>provider</code> + <code>subject</code>) in <code>linkedAccounts</code></p>
</li>
<li><p>If found, update the cached profile and return the existing <code>userId</code> — same user, no new records</p>
</li>
<li><p>If not found, try to match by email in case this user already signed up a different way</p>
</li>
<li><p>If still no match, create a new user and link the OAuth identity to it</p>
</li>
</ol>
<p>This design means a user who authenticates with Google and later with GitHub ends up with one <code>users</code> record and two <code>linkedAccounts</code> rows, both pointing to the same Convex user ID.</p>
<h3 id="heading-game-management">Game Management</h3>
<p>Create <code>convex/games.ts</code>. This file manages the full lifecycle of a game — creation, incremental state updates, and the final atomic write when a game ends.</p>
<pre><code class="language-typescript">import { query, mutation } from "./_generated/server";
import { v } from "convex/values";

export const createGame = mutation({
  args: {
    userId: v.optional(v.id("users")),
    public: v.optional(v.boolean()),
    seed: v.optional(v.number()),
    board: v.optional(v.array(v.number())),
    currentPiece: v.optional(v.object({ type: v.string(), rotation: v.number(), x: v.number(), y: v.number() })),
    nextQueue: v.optional(v.array(v.string())),
    holdPiece: v.optional(v.string()),
  },
  handler: async (ctx, args) =&gt; {
    const now = Date.now();
    const inserted = await ctx.db.insert("games", {
      userId: args.userId,
      status: "active",
      score: 0,
      level: 1,
      linesCleared: 0,
      board: args.board ?? [],
      currentPiece: args.currentPiece,
      nextQueue: args.nextQueue ?? [],
      holdPiece: args.holdPiece,
      seed: args.seed,
      replayId: undefined,
      public: args.public ?? false,
      updatedAt: now,
    });

    return inserted;
  },
});

export const getGame = query({
  args: { gameId: v.id("games") },
  handler: async (ctx, { gameId }) =&gt; {
    return await ctx.db.get(gameId);
  },
});

export const listByUser = query({
  args: { userId: v.id("users"), status: v.optional(v.string()) },
  handler: async (ctx, { userId, status }) =&gt; {
    const q = ctx.db.query("games").withIndex("by_user", (q) =&gt; q.eq("userId", userId));
    const all = await q.collect();
    if (status) return all.filter((g: any) =&gt; g.status === status);
    return all;
  },
});

export const patchGame = mutation({
  args: {
    gameId: v.id("games"),
    status: v.optional(v.union(v.literal("active"), v.literal("paused"), v.literal("finished"), v.literal("abandoned"))),
    score: v.optional(v.number()),
    level: v.optional(v.number()),
    linesCleared: v.optional(v.number()),
    board: v.optional(v.array(v.number())),
    currentPiece: v.optional(v.object({ type: v.string(), rotation: v.optional(v.number()), x: v.number(), y: v.number() })),
    nextQueue: v.optional(v.array(v.string())),
    holdPiece: v.optional(v.string()),
    seed: v.optional(v.number()),
    replayId: v.optional(v.id("replays")),
    public: v.optional(v.boolean()),
  },
  handler: async (ctx, args) =&gt; {
    // Build a patch object with only the fields that were explicitly provided.
    // This prevents an accidental undefined from overwriting real data.
    const patch: Record&lt;string, any&gt; = { updatedAt: Date.now() };
    if (args.status !== undefined) patch.status = args.status;
    if (args.score !== undefined) patch.score = args.score;
    if (args.level !== undefined) patch.level = args.level;
    if (args.linesCleared !== undefined) patch.linesCleared = args.linesCleared;
    if (args.board !== undefined) patch.board = args.board;
    if (args.currentPiece !== undefined) patch.currentPiece = args.currentPiece;
    if (args.nextQueue !== undefined) patch.nextQueue = args.nextQueue;
    if (args.holdPiece !== undefined) patch.holdPiece = args.holdPiece;
    if (args.seed !== undefined) patch.seed = args.seed;
    if (args.replayId !== undefined) patch.replayId = args.replayId;
    if (args.public !== undefined) patch.public = args.public;

    return await ctx.db.patch(args.gameId, patch);
  },
});

export const setStatus = mutation({
  args: { gameId: v.id("games"), status: v.union(v.literal("active"), v.literal("paused"), v.literal("finished"), v.literal("abandoned")) },
  handler: async (ctx, { gameId, status }) =&gt; {
    return await ctx.db.patch(gameId, { status, updatedAt: Date.now() });
  },
});

export const finishGame = mutation({
  args: {
    gameId: v.id("games"),
    score: v.number(),
    level: v.number(),
    linesCleared: v.number(),
    replayActions: v.optional(v.array(v.object({ t: v.number(), a: v.string(), p: v.optional(v.object({})) }))),
    durationMs: v.optional(v.number()),
    userId: v.optional(v.id("users")),
  },
  handler: async (ctx, { gameId, score, level, linesCleared, replayActions, durationMs, userId }) =&gt; {
    const now = Date.now();
    const game = await ctx.db.get(gameId);
    if (!game) throw new Error("Game not found");

    // Use the userId passed in, or fall back to the one stored on the game record
    const finalUserId = userId ?? game.userId;

    let replayId = game.replayId ?? undefined;

    // Save the replay if any actions were recorded during the game
    if (replayActions &amp;&amp; replayActions.length &gt; 0) {
      const insertedReplay = await ctx.db.insert("replays", {
        gameId,
        userId: finalUserId,
        actions: replayActions,
        durationMs: durationMs ?? 0,
      });
      replayId = insertedReplay;
    }

    // Mark the game finished with final stats
    await ctx.db.patch(gameId, {
      userId: finalUserId,
      status: "finished",
      score,
      level,
      linesCleared,
      replayId,
      updatedAt: now,
    });

    // Only add a leaderboard entry for authenticated users — anonymous games
    // are saved but don't appear in the public rankings
    if (finalUserId) {
      await ctx.db.insert("leaderboards", {
        userId: finalUserId,
        score,
        level,
        linesCleared,
      });
    }

    return await ctx.db.get(gameId);
  },
});

export const deleteGame = mutation({
  args: { gameId: v.id("games") },
  handler: async (ctx, { gameId }) =&gt; {
    return await ctx.db.delete(gameId);
  },
});

export const listPublicFinishedGames = query({
  args: { limit: v.optional(v.number()) },
  handler: async (ctx, { limit }) =&gt; {
    const finished = await ctx.db.query("games").withIndex("by_status", (q) =&gt; q.eq("status", "finished")).collect();
    const publicOnes = (finished as any[]).filter((g) =&gt; g.public === true);
    if (limit) return publicOnes.slice(0, limit);
    return publicOnes;
  },
});

export const getTopLeaderboard = query({
  args: { limit: v.optional(v.number()) },
  handler: async (ctx, { limit }) =&gt; {
    const all = await ctx.db.query("leaderboards").withIndex("by_score", (q) =&gt; q).collect();
    const sorted = (all as any[]).sort((a, b) =&gt; b.score - a.score);
    if (limit) return sorted.slice(0, limit);
    return sorted;
  },
});
</code></pre>
<p><code>finishGame</code> is the most important mutation in the entire app. All of these writes — creating the replay, updating the game's status, and inserting the leaderboard entry — happen inside a single Convex mutation, which means they run in a single transaction.</p>
<p>Either all of them succeed or none of them commit. You'll never end up with a finished game that has no leaderboard entry, or a leaderboard entry pointing to a game that was never marked finished.</p>
<h3 id="heading-replay-and-leaderboard-functions">Replay and Leaderboard Functions</h3>
<p>Create <code>convex/replays.ts</code>. The key query to understand here is <code>getRecentReplaysWithDetails</code>, which joins replay records with their related game and user data in one call. Convex doesn't have SQL-style JOINs, so the idiomatic pattern is to fetch the related IDs in one query and then resolve them with <code>Promise.all</code>.</p>
<pre><code class="language-typescript">import { query, mutation } from "./_generated/server";
import { v } from "convex/values";

export const createReplay = mutation({
  args: {
    gameId: v.id("games"),
    userId: v.optional(v.id("users")),
    actions: v.array(v.object({ t: v.number(), a: v.string(), p: v.optional(v.object({})) })),
    durationMs: v.number(),
  },
  handler: async (ctx, { gameId, userId, actions, durationMs }) =&gt; {
    return await ctx.db.insert("replays", {
      gameId,
      userId,
      actions,
      durationMs,
    });
  },
});

export const getReplay = query({
  args: { replayId: v.id("replays") },
  handler: async (ctx, { replayId }) =&gt; {
    const replay = await ctx.db.get(replayId);
    if (!replay) return null;
    // Eagerly load the related game and user so the viewer component
    // gets everything it needs in one round trip
    const game = await ctx.db.get(replay.gameId);
    const user = replay.userId ? await ctx.db.get(replay.userId) : null;
    return { ...replay, game, user };
  },
});

export const listByGame = query({
  args: { gameId: v.id("games") },
  handler: async (ctx, { gameId }) =&gt; {
    return await ctx.db
      .query("replays")
      .withIndex("by_game", (q) =&gt; q.eq("gameId", gameId))
      .collect();
  },
});

export const listByUser = query({
  args: { userId: v.id("users") },
  handler: async (ctx, { userId }) =&gt; {
    return await ctx.db
      .query("replays")
      .filter((q) =&gt; q.eq(q.field("userId"), userId))
      .collect();
  },
});

export const patchReplay = mutation({
  args: {
    replayId: v.id("replays"),
    actions: v.optional(v.array(v.object({ t: v.number(), a: v.string(), p: v.optional(v.object({})) }))),
    durationMs: v.optional(v.number()),
  },
  handler: async (ctx, { replayId, actions, durationMs }) =&gt; {
    const patch: Record&lt;string, any&gt; = {};
    if (actions !== undefined) patch.actions = actions;
    if (durationMs !== undefined) patch.durationMs = durationMs;
    return await ctx.db.patch(replayId, patch);
  },
});

export const deleteReplay = mutation({
  args: { replayId: v.id("replays") },
  handler: async (ctx, { replayId }) =&gt; {
    return await ctx.db.delete(replayId);
  },
});

export const getRecentReplays = query({
  args: { limit: v.optional(v.number()) },
  handler: async (ctx, { limit }) =&gt; {
    return await ctx.db
      .query("replays")
      .order("desc")
      .take(limit ?? 10);
  },
});

export const getRecentReplaysWithDetails = query({
  args: { limit: v.optional(v.number()) },
  handler: async (ctx, { limit }) =&gt; {
    const replays = await ctx.db
      .query("replays")
      .order("desc")
      .take(limit ?? 10);
    
    // For each replay, fetch the related game and user records in parallel.
    // This is Convex's pattern for relational data — Promise.all keeps it
    // efficient by firing all lookups concurrently rather than one at a time.
    const withDetails = await Promise.all(
      replays.map(async (replay) =&gt; {
        const game = replay.gameId ? await ctx.db.get(replay.gameId) : null;
        const user = replay.userId ? await ctx.db.get(replay.userId) : null;

        return {
          ...replay,
          game,
          user,
        };
      })
    );

    return withDetails;
  },
});

export const getTopReplays = query({
  args: { limit: v.optional(v.number()) },
  handler: async (ctx, { limit }) =&gt; {
    // Fetch all replays, then look up each game's score for sorting.
    // For large datasets you'd want a denormalized score field on the replay
    // itself, but at this scale this approach is simple and readable.
    const replays = await ctx.db.query("replays").collect();

    const withScores = await Promise.all(
      replays.map(async (replay) =&gt; {
        const game = await ctx.db.get(replay.gameId);
        return {
          ...replay,
          game,
          score: game?.score ?? 0,
        };
      })
    );

    const sorted = withScores.sort((a, b) =&gt; b.score - a.score);

    return limit ? sorted.slice(0, limit) : sorted;
  },
});
</code></pre>
<p>Now create <code>convex/leaderboards.ts</code>. A leaderboard entry is a denormalized snapshot — a point-in-time record of one game result. We don't update entries in place; every finished game creates a new row. That keeps writes simple and makes querying the historical record straightforward.</p>
<pre><code class="language-typescript">import { query, mutation } from "./_generated/server";
import { v } from "convex/values";

export const insertScore = mutation({
  args: {
    userId: v.id("users"),
    score: v.number(),
    level: v.number(),
    linesCleared: v.number(),
  },
  handler: async (ctx, { userId, score, level, linesCleared }) =&gt; {
    const now = Date.now();
    return await ctx.db.insert("leaderboards", {
      userId,
      score,
      level,
      linesCleared,
    });
  },
});

export const getEntry = query({
  args: { entryId: v.id("leaderboards") },
  handler: async (ctx, { entryId }) =&gt; {
    return await ctx.db.get(entryId);
  },
});

export const listTop = query({
  args: { limit: v.optional(v.number()) },
  handler: async (ctx, { limit }) =&gt; {
    // Convex doesn't yet support ORDER BY DESC on indexed queries, so we fetch
    // all entries and sort in application code. For very large tables you'd
    // want to cap the initial query, but for a game leaderboard this is fine.
    const all = await ctx.db.query("leaderboards").withIndex("by_score").collect();
    const sorted = (all as any[]).sort((a, b) =&gt; b.score - a.score);
    if (limit) return sorted.slice(0, limit);
    return sorted;
  },
});

export const listByUser = query({
  args: { userId: v.id("users") },
  handler: async (ctx, { userId }) =&gt; {
    return await ctx.db.query("leaderboards").filter((q) =&gt; q.eq(q.field("userId"), userId)).collect();
  },
});

export const patchEntry = mutation({
  args: {
    entryId: v.id("leaderboards"),
    score: v.optional(v.number()),
    level: v.optional(v.number()),
    linesCleared: v.optional(v.number()),
  },
  handler: async (ctx, { entryId, score, level, linesCleared }) =&gt; {
    const patch: Record&lt;string, any&gt; = {};
    if (score !== undefined) patch.score = score;
    if (level !== undefined) patch.level = level;
    if (linesCleared !== undefined) patch.linesCleared = linesCleared;
    return await ctx.db.patch(entryId, patch);
  },
});

export const deleteEntry = mutation({
  args: { entryId: v.id("leaderboards") },
  handler: async (ctx, { entryId }) =&gt; {
    return await ctx.db.delete(entryId);
  },
});

export const pruneOldEntries = mutation({
  args: { maxAgeMs: v.number() },
  handler: async (ctx, { maxAgeMs }) =&gt; {
    const cutoff = Date.now() - maxAgeMs;
    const all = await ctx.db.query("leaderboards").collect();
    // Delete entries whose _creationTime falls before the cutoff
    const toDelete = (all as any[]).filter((e) =&gt; (e._creationTime || 0) &lt; cutoff);
    for (const e of toDelete) await ctx.db.delete(e._id);
    return toDelete.length;
  },
});
</code></pre>
<h3 id="heading-understanding-convex-reactivity">Understanding Convex Reactivity</h3>
<p>Create a quick test in <code>app/page.tsx</code> to see real-time updates in action:</p>
<pre><code class="language-typescript">"use client";
import { useQuery } from "convex/react";
import { api } from "@/convex/_generated/api";

export default function Home() {
  const leaderboard = useQuery(api.leaderboards.getTop, { limit: 5 });

  return (
    &lt;ul&gt;
      {leaderboard?.map((entry, idx) =&gt; (
        &lt;li key={entry._id}&gt;
          #{idx + 1} {entry.userName} — {entry.score.toLocaleString()}
        &lt;/li&gt;
      ))}
    &lt;/ul&gt;
  );
}
</code></pre>
<p>Open this page in two browser windows. When you add a new score via the Convex dashboard, both windows update within 50ms without any polling code. This is how your leaderboard will work in production.</p>
<p>When you call <code>useQuery</code>, Convex executes the query function on the server, returns the current results to your component, and automatically subscribes the client to any future changes in the tables that query touched.</p>
<p>When a mutation writes to one of those tables, Convex re-runs the query, diffs the results against what the client already has, and pushes only what changed. You write zero synchronization code — the subscription is established automatically and cleaned up when the component unmounts.</p>
<h2 id="heading-building-the-tetris-game-engine">Building the Tetris Game Engine</h2>
<p>The game engine lives entirely in <code>components/tetris/GameBoard.tsx</code>. This section walks through every part of it: constants, utility functions, state management, the game loop, and MCP integration.</p>
<h3 id="heading-constants-and-piece-definitions">Constants and Piece Definitions</h3>
<pre><code class="language-typescript">const WIDTH = 10;
const HEIGHT = 20;

const PIECES: Record&lt;string, number[][]&gt; = {
  I: [[1, 1, 1, 1]],
  O: [[1, 1], [1, 1]],
  T: [[0, 1, 0], [1, 1, 1]],
  S: [[0, 1, 1], [1, 1, 0]],
  Z: [[1, 1, 0], [0, 1, 1]],
  J: [[1, 0, 0], [1, 1, 1]],
  L: [[0, 0, 1], [1, 1, 1]],
};

const PIECE_COLORS: Record&lt;string, string&gt; = {
  I: "#00f0f0",
  O: "#f0f000",
  T: "#a000f0",
  S: "#00f000",
  Z: "#f00000",
  J: "#0000f0",
  L: "#f0a000",
};

const PIECE_TYPES = Object.keys(PIECES);
</code></pre>
<p>Each piece is stored as the smallest bounding box containing its shape, where 1 is a filled cell and 0 is transparent. This keeps rotation math simple and collision detection fast. The colors follow The Tetris Company's official guidelines, making the game instantly recognizable.</p>
<h3 id="heading-core-utility-functions">Core Utility Functions</h3>
<p>These three functions are used throughout the engine. They are separated here because each one is independently testable and reused in multiple places.</p>
<h4 id="heading-empty-board">Empty board</h4>
<pre><code class="language-typescript">function emptyBoard() {
  return Array.from({ length: HEIGHT }, () =&gt;
    Array.from({ length: WIDTH }, () =&gt; 0)
  );
}
</code></pre>
<h4 id="heading-rotation-90-degrees-clockwise">Rotation (90 degrees clockwise)</h4>
<pre><code class="language-typescript">function rotate(shape: number[][]) {
  const h = shape.length;
  const w = shape[0].length;
  const out = Array.from({ length: w }, () =&gt;
    Array.from({ length: h }, () =&gt; 0)
  );
  for (let r = 0; r &lt; h; r++) {
    for (let c = 0; c &lt; w; c++) {
      out[c][h - 1 - r] = shape[r][c];
    }
  }
  return out;
}
</code></pre>
<p>The transformation <code>out[c][h - 1 - r] = shape[r][c]</code> transposes the matrix and reverses rows in one pass. For the T-piece, <code>[0,1,0] / [1,1,1]</code> becomes <code>[1,0] / [1,1] / [1,0]</code>.</p>
<h4 id="heading-collision-detection">Collision detection</h4>
<p>The most critical function, runs up to 60 times per second:</p>
<pre><code class="language-typescript">function canPlace(board: number[][], shape: number[][], x: number, y: number) {
  for (let r = 0; r &lt; shape.length; r++) {
    for (let c = 0; c &lt; shape[0].length; c++) {
      if (!shape[r][c]) continue;          // Skip empty cells immediately
      const br = y + r;
      const bc = x + c;
      if (bc &lt; 0 || bc &gt;= WIDTH || br &lt; 0 || br &gt;= HEIGHT) return false;
      if (board[br][bc]) return false;
    }
  }
  return true;
}
</code></pre>
<p>The early <code>continue</code> on empty cells is the key optimization. Most cells in a piece's bounding box are empty, so this skips the majority of iterations.</p>
<h3 id="heading-react-state-management">React State Management</h3>
<pre><code class="language-typescript">export default function GameBoard() {
  const [board, setBoard] = useState&lt;number[][]&gt;(emptyBoard());
  const [current, setCurrent] = useState&lt;{
    type: string; shape: number[][]; x: number; y: number;
  } | null&gt;(null);
  const [next, setNext] = useState&lt;string&gt;(
    () =&gt; PIECE_TYPES[Math.floor(Math.random() * PIECE_TYPES.length)]
  );
  const [running, setRunning] = useState(false);
  const [score, setScore] = useState(0);
  const [lines, setLines] = useState(0);
  const [level, setLevel] = useState(1);
  const [musicEnabled, setMusicEnabled] = useState(true);
  const [clearingRows, setClearingRows] = useState&lt;number[]&gt;([]);

  // Refs for values that shouldn't trigger re-renders
  const actionsRef = useRef&lt;any[]&gt;([]);
  const tickRef = useRef&lt;number | null&gt;(null);
  const bgMusicRef = useRef&lt;HTMLAudioElement | null&gt;(null);
  const clearSoundRef = useRef&lt;HTMLAudioElement | null&gt;(null);

  // MCP integration hooks from Vercel template
  const callTool = useCallTool();
  const sendMessage = useSendMessage();
  const [gameId, setGameId] = useState&lt;Id&lt;"games"&gt; | null&gt;(null);
  const startTimeRef = useRef&lt;number | null&gt;(null);
}
</code></pre>
<p>The distinction between <code>useState</code> and <code>useRef</code> matters for performance. <code>actionsRef</code> grows with every keypress and if it were state, every move would trigger a re-render and cause lag. <code>tickRef</code> holds the interval ID, which only needs to exist for cleanup. <code>bgMusicRef</code> holds an Audio element that never appears in the UI. None of these need to cause re-renders, so all three are refs.</p>
<h3 id="heading-audio-system">Audio System</h3>
<pre><code class="language-typescript">useEffect(() =&gt; {
  bgMusicRef.current = new Audio(
    "https://cdn.freesound.org/previews/612/612091_3283808-lq.mp3"
  );
  bgMusicRef.current.loop = true;
  bgMusicRef.current.volume = 0.3;

  clearSoundRef.current = new Audio(
    "https://cdn.freesound.org/previews/341/341695_5858296-lq.mp3"
  );
  clearSoundRef.current.volume = 0.5;

  return () =&gt; {
    bgMusicRef.current?.pause();
    bgMusicRef.current = null;
    clearSoundRef.current = null;
  };
}, []);

useEffect(() =&gt; {
  if (running &amp;&amp; musicEnabled &amp;&amp; bgMusicRef.current?.paused) {
    bgMusicRef.current.currentTime = 0;
    bgMusicRef.current.play().catch((e) =&gt; console.log("Music play failed:", e));
  } else if (!running || !musicEnabled) {
    bgMusicRef.current?.pause();
  }
}, [running, musicEnabled]);
</code></pre>
<p>The <code>.catch()</code> on <code>.play()</code> is essential. Browsers block autoplay audio until the user has interacted with the page and without it, you'd get uncaught promise rejections on every game start.</p>
<h3 id="heading-game-loop">Game Loop</h3>
<pre><code class="language-typescript">useEffect(() =&gt; {
  if (running) {
    const interval = Math.max(200, 1000 - (level - 1) * 100);
    tickRef.current = window.setInterval(() =&gt; {
      if (current) move(0, 1);
    }, interval);
    return () =&gt; { if (tickRef.current) clearInterval(tickRef.current); };
  } else {
    if (tickRef.current) clearInterval(tickRef.current);
  }
}, [running, current, level]);
</code></pre>
<p>The gravity speed formula produces this curve:</p>
<pre><code class="language-plaintext">Level 1:  1000ms per drop  (relaxed)
Level 5:   600ms per drop
Level 10:  200ms per drop  (capped, very fast)
</code></pre>
<p>The dependency array includes <code>current</code> because the interval's callback closes over it. When a new piece spawns, <code>current</code> changes, the effect re-runs, and the interval restarts with the correct piece reference. Without this, the interval would hold a stale closure and pieces would behave incorrectly.</p>
<h3 id="heading-keyboard-controls">Keyboard Controls</h3>
<pre><code class="language-typescript">useEffect(() =&gt; {
  const handler = (e: KeyboardEvent) =&gt; {
    if (!running) return;
    if (e.key === "ArrowLeft")  { e.preventDefault(); move(-1, 0, "L"); }
    if (e.key === "ArrowRight") { e.preventDefault(); move(1, 0, "R"); }
    if (e.key === "ArrowDown")  { e.preventDefault(); move(0, 1, "D"); }
    if (e.key === " " || e.key === "ArrowUp") {
      e.preventDefault();
      rotateCurrent("ROT");
    }
  };
  window.addEventListener("keydown", handler);
  return () =&gt; window.removeEventListener("keydown", handler);
}, [running, current]);
</code></pre>
<p><code>e.preventDefault()</code> on arrow keys prevents the browser from scrolling the page while the game is active.</p>
<h3 id="heading-piece-spawning">Piece Spawning</h3>
<pre><code class="language-typescript">function spawnNext(boardParam?: number[][]) {
  const currentBoard = boardParam ?? board;
  const type = next;
  const shape = PIECES[type].map((r) =&gt; [...r]);  // Deep copy
  const x = Math.floor((WIDTH - shape[0].length) / 2);
  const y = 0;

  if (!canPlace(currentBoard, shape, x, y)) {
    finish();  // Top of board is blocked — game over
    return;
  }

  setCurrent({ type, shape, x, y });
  setNext(PIECE_TYPES[Math.floor(Math.random() * PIECE_TYPES.length)]);
}
</code></pre>
<p>The <code>boardParam</code> parameter exists because of a React state timing issue. After clearing lines, <code>setBoard(newBoard)</code> schedules a state update, but the next render hasn't happened yet. If you called <code>spawnNext()</code> without the parameter, it would read the stale board state and spawn the piece on the old board. Passing <code>newBoard</code> directly bypasses this:</p>
<pre><code class="language-typescript">setTimeout(() =&gt; {
  setBoard(newBoard);
  setCurrent(null);
  setClearingRows([]);
  setTimeout(() =&gt; spawnNext(newBoard), 50);  // Pass new board explicitly
}, 300);
</code></pre>
<h3 id="heading-merging-piece-with-board">Merging Piece with Board</h3>
<pre><code class="language-typescript">function mergeCurrentToBoard(brd: number[][], cur: any) {
  const copy = brd.map((r) =&gt; [...r]);
  if (!cur) return copy;

  for (let r = 0; r &lt; cur.shape.length; r++) {
    for (let c = 0; c &lt; cur.shape[0].length; c++) {
      if (cur.shape[r][c]) {
        const rr = cur.y + r;
        const cc = cur.x + c;
        if (rr &gt;= 0 &amp;&amp; rr &lt; HEIGHT &amp;&amp; cc &gt;= 0 &amp;&amp; cc &lt; WIDTH) {
          copy[rr][cc] = PIECE_TYPES.indexOf(cur.type) + 10;
        }
      }
    }
  }
  return copy;
}
</code></pre>
<p>The <code>+10</code> offset encodes falling pieces differently from locked pieces:</p>
<pre><code class="language-plaintext">0      = empty cell
1-7    = locked piece (I=1, O=2, T=3, ...)
10-16  = currently falling piece (I=10, O=11, T=12, ...)
</code></pre>
<p>This lets the renderer apply different styles to falling vs. locked pieces, so you could add opacity, glow, or borders to the active piece without touching locked cells.</p>
<h3 id="heading-line-clearing">Line Clearing</h3>
<pre><code class="language-typescript">function clearLines(brd: number[][]) {
  let cleared = 0;
  const out: number[][] = [];

  for (let r = 0; r &lt; HEIGHT; r++) {
    if (brd[r].every((v) =&gt; v !== 0)) {
      cleared++;
    } else {
      out.push(brd[r]);
    }
  }

  while (out.length &lt; HEIGHT)
    out.unshift(Array.from({ length: WIDTH }, () =&gt; 0));

  return { board: out, cleared };
}
</code></pre>
<p>Full rows are filtered out, then empty rows are added at the top with <code>unshift</code> (not <code>push</code>), because gravity pulls pieces down, so new empty space must appear at the top.</p>
<h3 id="heading-the-movement-function">The Movement Function</h3>
<p>This is where collision detection, locking, line clearing, and spawning all connect:</p>
<pre><code class="language-typescript">function move(dx: number, dy: number, actionCode?: string) {
  if (!current) return;

  const nx = current.x + dx;
  const ny = current.y + dy;

  if (canPlace(board, current.shape, nx, ny)) {
    setCurrent({ ...current, x: nx, y: ny });
    if (actionCode) actionsRef.current.push({ t: Date.now(), a: actionCode });
  } else if (dy === 1) {
    // Downward move failed — piece has landed
    const merged = mergeCurrentToBoard(board, current);
    const normalized = merged.map((r) =&gt;
      r.map((v) =&gt; (v &gt;= 10 ? v - 9 : v))  // Convert falling values to locked
    );

    const { board: newBoard, cleared } = clearLines(normalized);

    if (cleared &gt; 0) {
      // Play sound
      if (clearSoundRef.current &amp;&amp; musicEnabled) {
        clearSoundRef.current.currentTime = 0;
        clearSoundRef.current.play().catch(console.log);
      }

      // Identify which rows flash
      const clearingRowIndices: number[] = [];
      for (let r = 0; r &lt; HEIGHT; r++) {
        if (normalized[r].every((v) =&gt; v !== 0)) clearingRowIndices.push(r);
      }
      setClearingRows(clearingRowIndices);

      // Update score and level
      setScore((s) =&gt; s + cleared * 100);
      setLines((prev) =&gt; {
        const newLines = prev + cleared;
        setLevel(Math.floor(newLines / 10) + 1);
        return newLines;
      });

      // Animate, then update board
      setTimeout(() =&gt; {
        setBoard(newBoard);
        setCurrent(null);
        setClearingRows([]);
        setTimeout(() =&gt; spawnNext(newBoard), 50);
      }, 300);
    } else {
      setBoard(newBoard);
      setCurrent(null);
      setTimeout(() =&gt; spawnNext(newBoard), 50);
    }
  }
  // Horizontal collision: do nothing (piece stays in place)
}
</code></pre>
<p>Only <code>dy === 1</code> failures trigger locking. A failed left or right move simply stops the piece; it doesn't land. The 300ms animation window gives players visual feedback before cleared rows disappear.</p>
<h3 id="heading-rotation">Rotation</h3>
<pre><code class="language-typescript">function rotateCurrent(actionCode?: string) {
  if (!current) return;
  const newShape = rotate(current.shape);
  if (canPlace(board, newShape, current.x, current.y)) {
    setCurrent({ ...current, shape: newShape });
    if (actionCode) actionsRef.current.push({ t: Date.now(), a: actionCode });
  }
}
</code></pre>
<p>If the rotated shape doesn't fit at the current position, nothing happens. A full implementation would add wall kicks (trying x±1, y-1 offsets before giving up), but this simplified version covers the vast majority of cases.</p>
<h3 id="heading-game-start-and-finish">Game Start and Finish</h3>
<pre><code class="language-typescript">async function start() {
  const b = emptyBoard();
  setBoard(b);
  setScore(0); setLines(0); setLevel(1);
  actionsRef.current = [];
  actionsRef.current.push({ t: Date.now(), a: "START" });

  setRunning(true);
  startTimeRef.current = Date.now();

  // Create game record via MCP tool (non-blocking)
  (async () =&gt; {
    try {
      const toolRes = await callTool?.("start_game", {});
      const gameIdToUse = (toolRes as any)?.structuredContent?.gameId;
      if (gameIdToUse) setGameId(gameIdToUse);
    } catch (err) {
      console.error("Failed to create game record:", err);
    }
  })();

  setTimeout(() =&gt; spawnNext(b), 10);
}
</code></pre>
<p>The MCP tool call is wrapped in a self-invoking async function so it doesn't block the game from starting. The board resets and the first piece spawns immediately; the game ID arrives asynchronously and is stored for use when the game ends.</p>
<pre><code class="language-typescript">async function finish() {
  setRunning(false);

  const durationMs = startTimeRef.current
    ? Date.now() - startTimeRef.current
    : undefined;
  const replayActions = actionsRef.current.slice();

  if (gameId &amp;&amp; callTool) {
    try {
      const result = await callTool("finish_game", {
        gameId, score, level,
        linesCleared: lines,
        replayActions,
        durationMs,
      });

      const message = (result as any)?.content?.[0]?.text
        || `Game finished! Score: \({score}, Level: \){level}, Lines: ${lines}`;

      await sendMessage?.(message);
    } catch (err) {
      // Graceful fallback — still show results even if save fails
      await sendMessage?.(
        `Game finished locally — Score: \({score}, Level: \){level} ` +
        `(Could not save: ${err instanceof Error ? err.message : String(err)})`
      );
    }
  } else {
    await sendMessage?.(
      `Game finished locally — Score: \({score}, Level: \){level} (No game ID)`
    );
  }

  // Reset all state
  setBoard(emptyBoard());
  setCurrent(null);
  setScore(0); setLines(0); setLevel(1);
  actionsRef.current = [];
  setGameId(null);
  startTimeRef.current = null;
}
</code></pre>
<p>The graceful degradation pattern is important: the game works even if the backend is unreachable. Players always see their score, and saving is a best-effort operation.</p>
<h3 id="heading-board-rendering">Board Rendering</h3>
<pre><code class="language-typescript">const display = mergeCurrentToBoard(board, current);
const cellPx = Math.max(18, Math.min(32, Math.floor(360 / WIDTH)));

function getCellColor(value: number): string {
  if (value === 0) return "#0f172a";
  const typeIndex = value &gt;= 10 ? value - 10 : value - 1;
  return PIECE_COLORS[PIECE_TYPES[typeIndex]];
}

return (
  &lt;div className="grid" style={{ gridTemplateColumns: `repeat(\({WIDTH}, \){cellPx}px)` }}&gt;
    {display.flatMap((row, r) =&gt;
      row.map((cell, c) =&gt; {
        const isClearing = clearingRows.includes(r);
        return (
          &lt;div
            key={`\({r}-\){c}`}
            style={{
              width: cellPx, height: cellPx,
              background: getCellColor(cell),
              border: "1px solid rgba(100,116,139,0.3)",
              opacity: isClearing ? 0.5 : 1,
              transform: isClearing ? "scale(1.05)" : "scale(1)",
              transition: "all 0.2s ease-in-out",
            }}
          /&gt;
        );
      })
    )}
  &lt;/div&gt;
);
</code></pre>
<p>Cell size is clamped between 18px (readable on mobile) and 32px (comfortable on desktop), fitting a 360px container. The clearing animation fades rows to 50% opacity and scales them slightly larger, a subtle pulse effect before they disappear.</p>
<h3 id="heading-control-buttons">Control Buttons</h3>
<pre><code class="language-typescript">&lt;div className="mt-3 flex gap-2"&gt;
  &lt;button onClick={start}&gt;Start&lt;/button&gt;
  &lt;button onClick={() =&gt; setRunning((s) =&gt; !s)}&gt;
    {running ? "Pause" : "Resume"}
  &lt;/button&gt;
  &lt;button onClick={() =&gt; rotateCurrent()}&gt;Rotate&lt;/button&gt;
  &lt;button onClick={() =&gt; move(0, 1, "D")}&gt;Drop&lt;/button&gt;
  &lt;button onClick={() =&gt; finish()}&gt;End&lt;/button&gt;
&lt;/div&gt;
</code></pre>
<p>These mirror the keyboard controls exactly, making the game fully playable on touch devices inside ChatGPT's iframe.</p>
<h3 id="heading-replay-recording">Replay Recording</h3>
<p>Throughout the component, every player action is stamped and stored:</p>
<pre><code class="language-typescript">actionsRef.current.push({ t: Date.now(), a: actionCode });
</code></pre>
<p>A typical game produces a few hundred actions totaling less than 20KB of JSON. Because a ref is used instead of state, recording has zero rendering overhead. When the game ends, <code>actionsRef.current.slice()</code> takes a snapshot of the array and passes it to <code>finish_game</code>, where Convex stores it alongside the final score.</p>
<h2 id="heading-implementing-kinde-oauth-authentication">Implementing Kinde OAuth Authentication</h2>
<p>Authentication in ChatGPT apps works differently from traditional web apps. ChatGPT is the OAuth client: it handles the redirect, code exchange, and token storage. Your app is the resource server. You receive tokens, validate them, and map OAuth identities to your database users.</p>
<h3 id="heading-oauth-architecture-overview">OAuth Architecture Overview</h3>
<pre><code class="language-plaintext">Layer 1: OAuth Discovery
  /.well-known/oauth-protected-resource
  -&gt; Tells ChatGPT where to authenticate

Layer 2: Token Extraction and Validation
  extractTokenFromArgs() -&gt; Find token in MCP context
  validateKindeToken()   -&gt; Verify signature with JWKS
  getKindeUserProfile()  -&gt; Fetch user details from Kinde

Layer 3: User Mapping
  requireAuthForTool()   -&gt; Protect MCP tools
  upsertLinkedAccount()  -&gt; Create/update Convex user
</code></pre>
<h3 id="heading-oauth-discovery-endpoint">OAuth Discovery Endpoint</h3>
<p>You created this in Section 3. Here is the full implementation with proper fallbacks:</p>
<pre><code class="language-typescript">// app/mcp/.well-known/oauth-protected-resource/route.ts
import { NextResponse } from 'next/server';

const MCP_SERVER_URL =
  process.env.MCP_AUDIENCE ||
  process.env.MCP_SERVER_URL ||
  `https://${process.env.VERCEL_URL || 'localhost'}`;

const DEFAULT_KINDE_ISSUER = 'https://devrelstudio.kinde.com';
const KINDE_ISSUER_URL =
  process.env.KINDE_ISSUER_URL ||
  process.env.KINDE_ISSUER ||
  DEFAULT_KINDE_ISSUER;

export async function GET() {
  const authServers = [KINDE_ISSUER_URL];
  console.log('oauth-protected-resource using authorization_servers:', authServers);

  return NextResponse.json({
    resource: MCP_SERVER_URL,
    authorization_servers: authServers,
    scopes_supported: ['openid', 'profile', 'email'],
    bearer_methods_supported: ['header'],
    resource_documentation: `${MCP_SERVER_URL}/docs`,
  });
}
</code></pre>
<p>When ChatGPT encounters a tool requiring authentication, it GETs this endpoint, reads <code>authorization_servers</code>, and redirects the user to Kinde. Without this endpoint, ChatGPT cannot discover your OAuth configuration and the entire auth flow breaks silently.</p>
<h3 id="heading-token-validation">Token Validation</h3>
<p>Update <code>app/lib/kinde.ts</code> with production-ready validation:</p>
<pre><code class="language-typescript">import { createRemoteJWKSet, jwtVerify } from 'jose';

const KINDE_ISSUER_URL = process.env.KINDE_ISSUER_URL || process.env.KINDE_ISSUER;
const MCP_AUDIENCE =
  process.env.MCP_AUDIENCE ||
  process.env.MCP_SERVER_URL ||
  process.env.NEXT_PUBLIC_MCP_AUDIENCE;

// getJwks is extracted into its own function so that createRemoteJWKSet is
// called once and reused across requests. createRemoteJWKSet handles HTTP
// caching internally, meaning Kinde's public keys are only fetched when the
// cache expires, not on every token validation. Creating a new instance per
// request would bypass this and add unnecessary latency.
function getJwks() {
  if (!KINDE_ISSUER_URL) {
    throw new Error('KINDE_ISSUER_URL (or KINDE_ISSUER) environment variable is not set');
  }
  return createRemoteJWKSet(new URL(`${KINDE_ISSUER_URL}/.well-known/jwks`));
}

export async function validateKindeToken(token: string) {
  if (!token) throw new Error('No token provided');
  if (!KINDE_ISSUER_URL) throw new Error('KINDE_ISSUER_URL not configured');
  if (!MCP_AUDIENCE) throw new Error('MCP_AUDIENCE (or MCP_SERVER_URL) not configured');

  const JWKS = getJwks();

  // jwtVerify does several things in one call:
  // 1. Decodes the JWT header and payload
  // 2. Fetches Kinde's public keys from the JWKS endpoint (using cached keys when available)
  // 3. Finds the matching key using the token's `kid` header field
  // 4. Verifies the cryptographic signature
  // 5. Checks that `iss` matches your Kinde domain and `aud` matches your MCP URL
  //
  // If the token was tampered with, expired, or issued by a different service,
  // jwtVerify throws and your handler never runs.
  const { payload } = await jwtVerify(token, JWKS, {
    issuer: KINDE_ISSUER_URL,
    audience: MCP_AUDIENCE,
  } as any);

  return payload as Record&lt;string, any&gt;;
}

export async function getKindeUserProfile(token: string) {
  if (!token) throw new Error('No token provided');
  if (!KINDE_ISSUER_URL) throw new Error('KINDE_ISSUER_URL not configured');

  const url = `${KINDE_ISSUER_URL}/oauth2/v2/user_profile`;
  const res = await fetch(url, {
    headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
  });

  if (!res.ok) {
    const txt = await res.text();
    throw new Error(`Failed to fetch user profile: \({res.status} \){txt}`);
  }

  return (await res.json()) as Record&lt;string, any&gt;;
}
</code></pre>
<p>The JWKS endpoint (<code>/.well-known/jwks</code>) returns Kinde's current public keys:</p>
<pre><code class="language-json">{
  "keys": [
    { "kty": "RSA", "use": "sig", "kid": "abc123", "n": "0vx7...", "e": "AQAB" }
  ]
}
</code></pre>
<p>Kinde signs tokens with its private key and you verify with the matching public key. Even if an attacker intercepts a token, they cannot forge new ones without the private key.</p>
<h3 id="heading-token-extraction-from-mcp-context">Token Extraction from MCP Context</h3>
<p>Before looking at the code, it is worth understanding why token extraction needs this much logic. The MCP protocol does not mandate a single canonical location for the Bearer token. Depending on the ChatGPT version, MCP transport (HTTP vs SSE), and how the SDK processes the request, the token can arrive in several different places: nested in the MCP context object under <code>requestInfo.headers</code>, on a raw <code>Request</code> object, flattened directly on <code>context.headers</code>, or not forwarded into the context at all because it was pre-registered against a request ID in an earlier middleware step.</p>
<p>The function below tries every known location in priority order, from most reliable to least, so your tool handlers work regardless of exactly how the token arrives.</p>
<p>Create <code>app/lib/mcpAuth.ts</code>:</p>
<pre><code class="language-typescript">import { getLastAuthToken } from './mcpRequestState';

export async function extractTokenFromArgs(args: any, context?: any) {
  // Strategy 1: context.requestInfo.headers
  // The most common location in MCP v1.0+ over HTTP. The header name may be
  // lowercase or Title-Case depending on which HTTP layer normalized it.
  if (context?.requestInfo?.headers) {
    const authHeader =
      context.requestInfo.headers.authorization ||
      context.requestInfo.headers.Authorization;
    if (authHeader?.startsWith('Bearer ')) {
      return authHeader.substring(7);
    }
  }

  // Strategy 2: context.request.headers
  // Used when the MCP handler passes a raw Fetch API Request object through
  // the context. Headers here are accessed with .get(), not dot notation.
  if (context?.request?.headers) {
    const authHeader =
      context.request.headers.get?.('Authorization') ||
      context.request.headers.get?.('authorization');
    if (authHeader?.startsWith('Bearer ')) return authHeader.substring(7);
  }

  // Strategy 3: context.headers directly
  // Some MCP transports flatten headers onto the context object itself.
  // We try both the Fetch API .get() method and plain property access to
  // cover both cases.
  if (context?.headers) {
    const authHeader =
      context.headers.get?.('Authorization') ||
      context.headers.get?.('authorization') ||
      context.headers.Authorization ||
      context.headers.authorization;
    if (authHeader &amp;&amp; typeof authHeader === 'string' &amp;&amp; authHeader.startsWith('Bearer ')) {
      return authHeader.substring(7);
    }
  }

  // Strategy 4: Request ID mapping
  // In some SSE-based transports, the Authorization header cannot be forwarded
  // through the MCP context. The POST handler middleware (see the POST export in
  // mcp/route.ts) pre-registers the token against every request ID found in the
  // body. Here we check whether any of the IDs in this context have a match.
  const possibleIds = [
    context?.requestId,
    context?.requestInfo?.requestId,
    context?.requestInfo?.id,
    context?.id,
    context?.sessionId,
    context?.requestInfo?.sessionId,
  ].filter(Boolean);

  for (const id of possibleIds) {
    const token = (await import('./mcpRequestMap')).getTokenForRequestId(id);
    if (token) return token;
  }

  // Strategy 5: Last known token (single-user fallback)
  // If all strategies above fail, return the most recently seen token for this
  // server process. This is safe in local development where only one user is
  // active, but should NOT be relied upon in a multi-user production deployment
  // where requests can interleave.
  const last = getLastAuthToken();
  if (last) return last;

  return null;
}
</code></pre>
<p>The two supporting files that Strategies 4 and 5 depend on are in-memory maps that live for the duration of the server process:</p>
<pre><code class="language-typescript">// app/lib/mcpRequestMap.ts
// Maps MCP request IDs to the Bearer token that arrived with them.
// Entries are written by the POST handler middleware before the MCP handler
// runs, ensuring the token is findable even after the original Request object
// is gone.
const tokenMap = new Map&lt;string, string&gt;();

export function setTokenForRequestId(id: string, token: string) {
  tokenMap.set(id, token);
}

export function getTokenForRequestId(id: string): string | null {
  return tokenMap.get(id) ?? null;
}

export function clearTokenForRequestId(id: string) {
  tokenMap.delete(id);
}

export function clearAll() {
  tokenMap.clear();
}
</code></pre>
<pre><code class="language-typescript">// app/lib/mcpRequestState.ts
// Tracks the most recently seen token as a last-resort fallback.
// Only appropriate for single-user or development scenarios.
let lastAuthToken: string | null = null;

export function setLastAuthToken(token: string | null) {
  lastAuthToken = token;
}

export function getLastAuthToken() {
  return lastAuthToken;
}
</code></pre>
<h3 id="heading-requiring-auth-in-mcp-tools">Requiring Auth in MCP Tools</h3>
<p>With token extraction handled, you can now write <code>requireAuthForTool</code>: the single function you call at the top of any protected tool handler. It extracts the token, validates it against Kinde's JWKS endpoint, and returns either the authenticated user's profile or a structured MCP error response that ChatGPT knows how to act on.</p>
<pre><code class="language-typescript">// app/lib/mcpAuth.ts (continued)
import { validateKindeToken, getKindeUserProfile } from './kinde';

const MCP_SERVER_URL =
  process.env.MCP_SERVER_URL ||
  process.env.MCP_AUDIENCE ||
  `https://${process.env.VERCEL_URL || 'localhost'}`;

// Builds the WWW-Authenticate challenge value pointing to your OAuth discovery
// endpoint. ChatGPT reads this value and uses it to initiate the Kinde login
// flow for the user.
export function makeAuthenticateMeta(message = 'Sign in required') {
  return [
    `Bearer resource_metadata="${MCP_SERVER_URL}/mcp/.well-known/oauth-protected-resource", ` +
    `error="insufficient_scope", error_description="${message}"`,
  ];
}

export async function requireAuthForTool(args: any, context?: any) {
  const token = await extractTokenFromArgs(args, context);

  if (!token) {
    // No token found anywhere. Return an MCP error with the WWW-Authenticate
    // challenge. ChatGPT reads the mcp/www_authenticate metadata field, fetches
    // your discovery endpoint, and redirects the user to Kinde to sign in.
    return {
      isError: true,
      content: [{ type: 'text', text: 'Please sign in to continue.' }],
      _meta: { 'mcp/www_authenticate': makeAuthenticateMeta() },
    };
  }

  try {
    const payload = await validateKindeToken(token).catch(() =&gt; null);
    const profile = await getKindeUserProfile(token).catch(() =&gt; null);

    if (!payload) {
      // Token arrived but failed cryptographic validation. Likely expired or tampered.
      return {
        isError: true,
        content: [{ type: 'text', text: 'Invalid token. Please sign in again.' }],
        _meta: { 'mcp/www_authenticate': makeAuthenticateMeta('Invalid token') },
      };
    }

    // Both checks passed. Return the validated identity to the caller.
    return { ok: true, token, profile, payload };
  } catch (err: any) {
    return {
      isError: true,
      content: [{ type: 'text', text: 'Authentication failed. Please sign in again.' }],
      _meta: { 'mcp/www_authenticate': makeAuthenticateMeta('Authentication failed') },
    };
  }
}
</code></pre>
<p>The <code>_meta['mcp/www_authenticate']</code> field follows RFC 6750 (Bearer Token Usage). When ChatGPT receives a tool response containing this field, it treats the response as an authentication challenge: it fetches your discovery endpoint, reads <code>authorization_servers</code>, redirects the user to Kinde, and re-calls the tool with the resulting token.</p>
<p>Your tool handler never needs to know whether a call was an initial attempt or a post-authentication retry. It calls <code>requireAuthForTool</code> at the top and proceeds if <code>ok</code> is true:</p>
<pre><code class="language-typescript">async (args, context) =&gt; {
  const auth = await requireAuthForTool(args, context);

  if ((auth as any).isError) {
    return auth; // Returns the auth challenge to ChatGPT
  }

  const { profile, payload } = auth as any;
  // User is authenticated — proceed with tool logic
}
</code></pre>
<h3 id="heading-linking-oauth-identities-to-convex-users">Linking OAuth Identities to Convex Users</h3>
<p>When a user authenticates, you map their Kinde identity to a Convex user. The <code>upsertLinkedAccount</code> mutation from Section 4 handles this. In your MCP tool handlers, call it like this:</p>
<pre><code class="language-typescript">const linkedUserId = await callConvexMutation(
  api.users.upsertLinkedAccount,
  {
    provider: 'kinde',
    providerUserId: String(payload.sub),  // e.g. "kinde|2151678548"
    email: profile?.email,
    displayName:
      `\({profile?.given_name || ''} \){profile?.family_name || ''}`.trim() ||
      profile?.email ||
      'Anonymous',
    avatarUrl: profile?.picture,
  }
);

const userId = String(linkedUserId);
</code></pre>
<p>The flow is: Kinde JWT arrives with <code>sub: "kinde|2151678548"</code> -&gt; check <code>linkedAccounts</code> for that provider/subject pair -&gt; if found, return the existing <code>userId</code> and update <code>lastSeenAt</code> -&gt; if not found, create a new user and linked account -&gt; return the new <code>userId</code>.</p>
<p>This means a player authenticating for the first time gets a new Convex user created automatically. The same player returning gets their existing account, preserving all their scores and replays.</p>
<h3 id="heading-token-extraction-in-the-post-handler">Token Extraction in the POST Handler</h3>
<p>The strategies in <code>extractTokenFromArgs</code> handle finding a token once the MCP handler is already running. But some transports consume the request body before the handler sees it, meaning the token in the <code>Authorization</code> header has no corresponding context to land in. This middleware solves that by reading the token and the request IDs from the raw body before the handler runs, storing each pairing in <code>mcpRequestMap</code> so Strategy 4 can find them later.</p>
<p>Add this to <code>mcp/route.ts</code> before the handler export:</p>
<pre><code class="language-typescript">export async function POST(req: Request) {
  let clonedReq = req;

  try {
    const authHeader =
      req.headers.get('Authorization') || req.headers.get('authorization');

    if (authHeader &amp;&amp; typeof authHeader === 'string' &amp;&amp; authHeader.startsWith('Bearer ')) {
      const token = authHeader.substring(7);
      const bodyText = await req.text();

      if (bodyText) {
        let parsed: any = null;
        try {
          parsed = JSON.parse(bodyText);
        } catch (e) {
          // Body is not JSON — skip ID collection
        }

        const idsToCheck: string[] = [];

        // collectIds walks the parsed body recursively because request IDs can
        // appear at different nesting depths depending on the MCP transport and
        // the JSON-RPC batch format. A shallow check would miss IDs nested inside
        // params or method objects.
        function collectIds(obj: any) {
          if (!obj || typeof obj !== 'object') return;
          for (const k of Object.keys(obj)) {
            if (k === 'requestId' || k === 'sessionId' || k === 'request_id' || k === 'id') {
              const v = obj[k];
              if (typeof v === 'string') idsToCheck.push(v);
            } else if (typeof obj[k] === 'object') {
              collectIds(obj[k]);
            }
          }
        }

        collectIds(parsed);

        for (const id of idsToCheck) {
          setTokenForRequestId(id, token);
        }

        // The request body can only be read once. Clone the request with the
        // already-read body text so the MCP handler can read it again normally.
        clonedReq = new Request(req.url, {
          method: req.method,
          headers: req.headers,
          body: bodyText,
        });
      }
    }

    return await handler(clonedReq);
  } catch (error) {
    throw error;
  }
}
</code></pre>
<h3 id="heading-validation-endpoint-for-debugging">Validation Endpoint for Debugging</h3>
<p>Create <code>app/api/mcp/validate-token/route.ts</code> to test token validation manually during development:</p>
<pre><code class="language-typescript">import { NextResponse } from 'next/server';
import { validateKindeToken, getKindeUserProfile } from '@/app/lib/kinde';

export async function POST(req: Request) {
  try {
    const authHeader = req.headers.get('authorization') || '';
    const tokenFromHeader = authHeader.startsWith('Bearer ')
      ? authHeader.replace('Bearer ', '')
      : undefined;
    const body = await req.json().catch(() =&gt; ({}));
    const token = tokenFromHeader || body.token;

    if (!token) {
      return NextResponse.json({ error: 'No token provided' }, { status: 401 });
    }

    const payload = await validateKindeToken(token);

    let profile = null;
    try {
      profile = await getKindeUserProfile(token);
    } catch (e) {
      // Non-fatal. The payload alone is enough to confirm the token is valid.
    }

    return NextResponse.json({ ok: true, payload, profile });
  } catch (err: any) {
    return NextResponse.json({ error: err?.message ?? String(err) }, { status: 401 });
  }
}
</code></pre>
<p>Test it with a token obtained from Kinde's OAuth Playground or a manual authorization flow:</p>
<pre><code class="language-shell">curl -X POST http://localhost:3000/api/mcp/validate-token \
  -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..." \
  -H "Content-Type: application/json"

# Success:
# { "ok": true, "payload": { "sub": "kinde|...", "email": "..." }, "profile": {...} }

# Expired token:
# { "error": "Token has expired" }
</code></pre>
<h3 id="heading-security-checklist">Security Checklist</h3>
<p>Before shipping, verify these practices are in place:</p>
<ol>
<li><p><strong>Server-side validation only.</strong> Never trust user-provided identity claims. Always call <code>validateKindeToken</code> on the server.</p>
</li>
<li><p><strong>Verify both issuer and audience.</strong> Without audience checking, a token issued for a different app would pass validation.</p>
</li>
<li><p><strong>JWKS caching.</strong> <code>createRemoteJWKSet</code> handles HTTP caching internally. Do not create a new instance per request.</p>
</li>
<li><p><strong>Fail fast on missing config.</strong> Throw at startup if <code>KINDE_ISSUER_URL</code> or <code>MCP_AUDIENCE</code> are missing rather than failing silently on the first real request.</p>
</li>
<li><p><strong>Graceful error responses.</strong> Return <code>isError: true</code> with a user-friendly message rather than exposing stack traces or token details.</p>
</li>
</ol>
<h2 id="heading-building-the-mcp-integration">Building the MCP Integration</h2>
<p>The MCP route is the core of your ChatGPT integration. It registers tools, handles authentication, calls Convex, and returns widgets. Everything from Sections 3 through 6 comes together here.</p>
<h3 id="heading-convex-http-client">Convex HTTP Client</h3>
<p>MCP tool handlers run in Next.js server context, not React. You cannot use <code>useQuery</code> or <code>useMutation</code>. Instead, use the Convex HTTP client directly. Create <code>app/lib/convex.ts</code>:</p>
<pre><code class="language-typescript">import { ConvexHttpClient } from "convex/browser";

// Singleton: ConvexHttpClient maintains a connection pool internally.
// Creating a new instance per request wastes those connections and
// adds latency to cold starts. One client shared across all requests
// is the correct pattern.
let client: ConvexHttpClient | null = null;

export function getConvexClient() {
  if (!client) {
    const url = process.env.NEXT_PUBLIC_CONVEX_URL;
    if (!url) throw new Error("NEXT_PUBLIC_CONVEX_URL is not set");
    client = new ConvexHttpClient(url);
  }
  return client;
}

export async function callConvexMutation&lt;T&gt;(
  fn: any,
  args: Record&lt;string, any&gt;
): Promise&lt;T&gt; {
  return getConvexClient().mutation(fn, args) as Promise&lt;T&gt;;
}

export async function callConvexQuery&lt;T&gt;(
  fn: any,
  args: Record&lt;string, any&gt;
): Promise&lt;T&gt; {
  return getConvexClient().query(fn, args) as Promise&lt;T&gt;;
}
</code></pre>
<h3 id="heading-widget-html-generation">Widget HTML Generation</h3>
<p>Before registering tools, you need a helper to render widget HTML. The imports below pull in everything the MCP route depends on. <code>zodToJsonSchema</code> is included here because the MCP SDK expects tool input schemas in JSON Schema format, not Zod format. <code>zodToJsonSchema</code> converts your Zod definitions at registration time so you get Zod's type safety when writing schemas and valid JSON Schema in the manifest ChatGPT reads.</p>
<pre><code class="language-typescript">import {
  createMcpHandler,
  experimental_withMcpAuth,
  getAppsSdkCompatibleHtml,
} from "mcp-handler";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
import { api } from "@/convex/_generated/api";
import { Id } from "@/convex/_generated/dataModel";
import { callConvexMutation, callConvexQuery } from "@/app/lib/convex";
import {
  extractTokenFromArgs,
  requireAuthForTool,
  setTokenForRequestId,
} from "@/app/lib/mcpAuth";
import { setLastAuthToken } from "@/app/lib/mcpRequestState";
import { baseURL } from "@/lib/baseURL";

// getAppsSdkCompatibleHtml wraps a URL in the HTML structure ChatGPT expects
// for interactive widgets. It sets the MIME type to text/html+skybridge, which
// tells ChatGPT to render the response as a sandboxed iframe rather than
// displaying it as plain text.
function makeWidgetHtml(path: string, params: Record&lt;string, string&gt; = {}) {
  const url = new URL(path, baseURL);
  Object.entries(params).forEach(([k, v]) =&gt; url.searchParams.set(k, v));
  return getAppsSdkCompatibleHtml(url.toString());
}
</code></pre>
<h3 id="heading-mcp-server-setup">MCP Server Setup</h3>
<pre><code class="language-typescript">const server = new Server(
  { name: "tetris-server", version: "1.0.0" },
  { capabilities: { tools: {} } }
);
</code></pre>
<h3 id="heading-tool-registration-startgame">Tool Registration: <code>start_game</code></h3>
<pre><code class="language-typescript">server.registerTool(
  "start_game",
  {
    description:
      "Start a new Tetris game. Returns an interactive widget for the user to play. " +
      "Authentication is optional — anonymous users can play but won't appear on the leaderboard.",
    inputSchema: zodToJsonSchema(
      z.object({
        public: z.boolean().optional()
          .describe("Whether to show this game on the leaderboard. Default false."),
        seed: z.number().optional()
          .describe("Random seed for reproducible piece sequences."),
      })
    ),
    // Two security schemes implement OR logic: ChatGPT calls this tool with
    // whatever auth state it currently has. If the user is signed in, the token
    // arrives and we link a Convex user. If not, the game starts anonymously.
    // A single scheme would make authentication required and block anonymous play.
    securitySchemes: [
      { type: "noauth" },
      { type: "oauth2", scopes: ["openid", "profile", "email"] },
    ],
  },
  async (args: any, context?: any) =&gt; {
    let userId: string | undefined;

    // Attempt auth but don't require it
    const token = await extractTokenFromArgs(args, context);
    if (token) {
      try {
        const authResult = await requireAuthForTool(args, context);
        if (!(authResult as any).isError) {
          const { profile, payload } = authResult as any;
          const linkedId = await callConvexMutation(
            api.users.upsertLinkedAccount,
            {
              provider: "kinde",
              providerUserId: String(payload.sub),
              email: profile?.email,
              displayName:
                `\({profile?.given_name || ""} \){profile?.family_name || ""}`.trim() ||
                profile?.email ||
                "Anonymous",
              avatarUrl: profile?.picture,
            }
          );
          userId = String(linkedId);
        }
      } catch (err) {
        console.error("[start_game] Auth error (proceeding anonymously):", err);
      }
    }

    const gameId = await callConvexMutation(api.games.createGame, {
      userId: userId ? (userId as Id&lt;"users"&gt;) : undefined,
      public: args.public ?? false,
      seed: args.seed,
    });

    const widget = makeWidgetHtml("/tetris/play", {
      gameId: String(gameId),
    });

    return {
      content: [{ type: "text", text: widget }],
      // structuredContent is returned alongside the widget HTML so that
      // GameBoard.tsx can extract the gameId from the callTool response
      // without parsing the HTML. See the start() function in GameBoard.tsx.
      structuredContent: { gameId: String(gameId) },
    };
  }
);
</code></pre>
<h3 id="heading-tool-registration-finishgame">Tool Registration: <code>finish_game</code></h3>
<pre><code class="language-typescript">server.registerTool(
  "finish_game",
  {
    description:
      "Record the final score for a completed Tetris game and save the replay. " +
      "Call this when the player's game ends.",
    inputSchema: zodToJsonSchema(
      z.object({
        gameId: z.string().describe("The game ID returned by start_game."),
        score: z.number().describe("Final score."),
        level: z.number().describe("Level reached."),
        linesCleared: z.number().describe("Total lines cleared."),
        replayActions: z
          .array(
            z.object({
              t: z.number().describe("Milliseconds since game start."),
              a: z.string().describe("Action code: L, R, D, ROT, HD, START."),
              d: z.any().optional(),
            })
          )
          .optional()
          .describe("Compact action log for replay playback."),
        durationMs: z.number().optional().describe("Total game duration in ms."),
      })
    ),
    securitySchemes: [
      { type: "noauth" },
      { type: "oauth2", scopes: ["openid", "profile", "email"] },
    ],
  },
  async (args: any, context?: any) =&gt; {
    try {
      const result = await callConvexMutation(api.games.finishGame, {
        gameId: args.gameId as Id&lt;"games"&gt;,
        // Number() coercion is defensive: ChatGPT's LLM occasionally serializes
        // numeric values as strings when constructing tool arguments. Coercing
        // explicitly here prevents type errors in the Convex mutation.
        score: Number(args.score),
        level: Number(args.level),
        linesCleared: Number(args.linesCleared),
        replayActions: args.replayActions ?? [],
        durationMs: args.durationMs ?? 0,
      });

      const score = Number(args.score);
      const lines = Number(args.linesCleared);
      const level = Number(args.level);

      const summary =
        `Game over! Final score: ${score.toLocaleString()} | ` +
        `Level: \({level} | Lines cleared: \){lines}. ` +
        (args.gameId ? `Replay saved (ID: ${String(result?.replayId ?? "").slice(0, 8)}...). ` : "") +
        (score &gt; 10000 ? "Excellent game!" : score &gt; 5000 ? "Nice run!" : "Keep practicing!");

      return {
        content: [{ type: "text", text: summary }],
        structuredContent: result,
      };
    } catch (err: any) {
      // Graceful fallback: always return a readable message rather than
      // letting the error surface as a raw exception in ChatGPT's UI.
      return {
        content: [
          {
            type: "text",
            text: `Score recorded locally: \({args.score}. (Save failed: \){err?.message ?? String(err)})`,
          },
        ],
      };
    }
  }
);
</code></pre>
<h3 id="heading-tool-registration-getleaderboard">Tool Registration: <code>get_leaderboard</code></h3>
<pre><code class="language-typescript">server.registerTool(
  "get_leaderboard",
  {
    description: "Get the current Tetris leaderboard widget showing top scores.",
    inputSchema: zodToJsonSchema(
      z.object({
        limit: z.number().optional()
          .describe("Number of entries to show. Default 10, max 25."),
      })
    ),
    // Read-only, public data. No auth needed and requiring it would add
    // unnecessary friction for a tool that reveals nothing sensitive.
    securitySchemes: [{ type: "noauth" }],
  },
  async (args: any, _context?: any) =&gt; {
    const limit = Math.min(Number(args.limit ?? 10), 25);

    try {
      const topScores = await callConvexQuery(api.leaderboards.getTop, { limit });

      const widget = makeWidgetHtml("/tetris/leaderboard", {
        limit: String(limit),
      });

      return {
        content: [{ type: "text", text: widget }],
        structuredContent: { topScores },
      };
    } catch (err: any) {
      return {
        content: [{ type: "text", text: `Failed to fetch leaderboard: ${err?.message}` }],
      };
    }
  }
);
</code></pre>
<h3 id="heading-tool-registration-viewreplay">Tool Registration: <code>view_replay</code></h3>
<pre><code class="language-typescript">server.registerTool(
  "view_replay",
  {
    description: "Watch a recorded Tetris game replay.",
    inputSchema: zodToJsonSchema(
      z.object({
        replayId: z.string().describe("The replay ID to watch."),
      })
    ),
    securitySchemes: [{ type: "noauth" }],
  },
  async (args: any, _context?: any) =&gt; {
    try {
      const replay = await callConvexQuery(api.replays.getReplay, {
        replayId: args.replayId as Id&lt;"replays"&gt;,
      });

      if (!replay) {
        return { content: [{ type: "text", text: "Replay not found." }] };
      }

      const widget = makeWidgetHtml("/tetris/replay", {
        replayId: args.replayId,
      });

      return {
        content: [{ type: "text", text: widget }],
        structuredContent: {
          replayId: args.replayId,
          score: replay.finalScore,
          level: replay.finalLevel,
          duration: replay.durationMs,
        },
      };
    } catch (err: any) {
      return {
        content: [{ type: "text", text: `Failed to load replay: ${err?.message}` }],
      };
    }
  }
);
</code></pre>
<h3 id="heading-wiring-up-the-handler-and-exports">Wiring Up the Handler and Exports</h3>
<p>With all four tools registered, the final step is exporting the route handler that Next.js calls for every incoming request. There is one problem to solve first: the <code>Authorization</code> header that ChatGPT sends with authenticated requests needs to reach your tool handlers, but by the time those handlers execute, the original <code>Request</code> object has already been consumed by the MCP SDK's request parsing. The header is gone.</p>
<p>The solution is a thin middleware layer inside the <code>POST</code> export. Before the request reaches the MCP handler, this middleware reads the <code>Authorization</code> header, walks the JSON body to find every ID field, and registers the token against each ID in <code>mcpRequestMap</code>. When <code>extractTokenFromArgs</code> runs inside your tool handler, Strategy 4 finds the token via the matching request ID.</p>
<pre><code class="language-typescript">const handler = createMcpHandler(server);

export async function POST(req: Request) {
  let clonedReq = req;

  try {
    const authHeader =
      req.headers.get("Authorization") || req.headers.get("authorization");

    if (authHeader?.startsWith("Bearer ")) {
      const token = authHeader.substring(7);

      // Store as the most recently seen token for the last-resort fallback
      // (Strategy 5 in extractTokenFromArgs)
      setLastAuthToken(token);

      // req.body is a ReadableStream that can only be consumed once.
      // We read it here, before the MCP handler sees it, so we can extract
      // request IDs. The request is then reconstructed below with the same
      // body text so the handler can read it normally.
      const bodyText = await req.text();

      if (bodyText) {
        let parsed: any = null;
        try { parsed = JSON.parse(bodyText); } catch (e) {}

        // Walk the parsed body recursively. Request IDs can appear at different
        // nesting depths depending on the MCP transport and JSON-RPC batch
        // format. A shallow check would miss IDs nested inside params objects.
        // We cast a wide net across all known ID field names because different
        // MCP versions use different conventions.
        function collectIds(obj: any) {
          if (!obj || typeof obj !== "object") return;
          for (const k of Object.keys(obj)) {
            if (["requestId", "sessionId", "request_id", "id"].includes(k)) {
              if (typeof obj[k] === "string") setTokenForRequestId(obj[k], token);
            } else if (typeof obj[k] === "object") {
              collectIds(obj[k]);
            }
          }
        }
        collectIds(parsed);

        // Reconstruct a fresh Request with the already-read body text.
        // Without this, the MCP handler receives a Request with an exhausted
        // body stream and fails to parse the incoming tool call.
        clonedReq = new Request(req.url, {
          method: req.method,
          headers: req.headers,
          body: bodyText,
        });
      }
    }

    return await handler(clonedReq);
  } catch (error) {
    throw error;
  }
}

// GET handles MCP capability discovery. When you register the connector in
// ChatGPT, it makes a GET request to your MCP endpoint to fetch the tool
// manifest: the list of available tools, their descriptions, and their
// input schemas. The same handler serves both purposes.
export const GET = handler;
</code></pre>
<h3 id="heading-mcp-documentation-endpoint">MCP Documentation Endpoint</h3>
<p>Create <code>app/mcp-docs/page.tsx</code>. This page is referenced in your environment variables and appears when users ask ChatGPT to explain your app's capabilities:</p>
<pre><code class="language-typescript">export default function McpDocsPage() {
  return (
    &lt;main style={{ fontFamily: "monospace", padding: "2rem", maxWidth: "600px" }}&gt;
      &lt;h1&gt;Tetris ChatGPT App — MCP Documentation&lt;/h1&gt;
      &lt;h2&gt;Available Tools&lt;/h2&gt;
      &lt;ul&gt;
        &lt;li&gt;
          &lt;strong&gt;start_game&lt;/strong&gt; — Starts a new Tetris game and returns a
          playable widget. Optional: &lt;code&gt;public&lt;/code&gt; (leaderboard), &lt;code&gt;seed&lt;/code&gt;{" "}
          (reproducible sequence).
        &lt;/li&gt;
        &lt;li&gt;
          &lt;strong&gt;finish_game&lt;/strong&gt; — Records the final score and saves the
          replay. Requires &lt;code&gt;gameId&lt;/code&gt;, &lt;code&gt;score&lt;/code&gt;,{" "}
          &lt;code&gt;level&lt;/code&gt;, &lt;code&gt;linesCleared&lt;/code&gt;.
        &lt;/li&gt;
        &lt;li&gt;
          &lt;strong&gt;get_leaderboard&lt;/strong&gt; — Returns the top scores widget.
          Optional: &lt;code&gt;limit&lt;/code&gt; (default 10).
        &lt;/li&gt;
        &lt;li&gt;
          &lt;strong&gt;view_replay&lt;/strong&gt; — Renders a recorded game replay.
          Requires &lt;code&gt;replayId&lt;/code&gt;.
        &lt;/li&gt;
      &lt;/ul&gt;
      &lt;h2&gt;Authentication&lt;/h2&gt;
      &lt;p&gt;
        All tools support anonymous access. Signing in via Kinde enables leaderboard
        entries and replay attribution.
      &lt;/p&gt;
    &lt;/main&gt;
  );
}
</code></pre>
<h3 id="heading-tool-design-principles">Tool Design Principles</h3>
<p>A few patterns from this implementation worth keeping in mind for any MCP tool you build.</p>
<p><strong>Defensive argument coercion.</strong> Use <code>Number(args.score)</code> and <code>String(args.gameId)</code> rather than trusting the types ChatGPT sends. The LLM occasionally serializes numeric values as strings when constructing tool arguments, and a type mismatch in a Convex mutation will throw rather than coerce silently.</p>
<p><strong>Structured content alongside widget HTML.</strong> Return <code>structuredContent</code> with key values even when the primary content is a widget. This lets <code>GameBoard.tsx</code> extract <code>gameId</code> directly from the <code>callTool</code> response and lets callers inspect results programmatically without parsing HTML.</p>
<p><strong>Graceful degradation in every handler.</strong> Wrap Convex calls in <code>try/catch</code> and return a meaningful error message rather than throwing. ChatGPT surfaces unhandled tool errors poorly; a friendly fallback keeps the user experience smooth even when the backend is unreachable.</p>
<p><strong>Minimal auth in read-only tools.</strong> For <code>get_leaderboard</code> and <code>view_replay</code>, skip auth entirely rather than attempting token extraction. These tools are read-only and public; adding auth would introduce friction and failure modes with no security benefit.</p>
<h3 id="heading-verifying-the-mcp-route">Verifying the MCP Route</h3>
<p>Test the tool listing endpoint locally:</p>
<pre><code class="language-shell">curl -X POST http://localhost:3000/mcp \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
</code></pre>
<p>Expected response:</p>
<pre><code class="language-json">{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "tools": [
      { "name": "start_game", "description": "Start a new Tetris game...", "inputSchema": {...} },
      { "name": "finish_game", "description": "Record the final score...", "inputSchema": {...} },
      { "name": "get_leaderboard", "description": "Get the current Tetris leaderboard...", "inputSchema": {...} },
      { "name": "view_replay", "description": "Watch a recorded Tetris game replay.", "inputSchema": {...} }
    ]
  }
}
</code></pre>
<p>If you see all four tools, the MCP server is configured correctly. If you see an empty array, check that all <code>server.registerTool</code> calls come before <code>createMcpHandler(server)</code>.</p>
<h2 id="heading-building-the-supporting-features">Building the Supporting Features</h2>
<p>With the game engine and MCP integration complete, this section builds the pages users actually see: the landing page, game interface, leaderboard, and replay viewer.</p>
<h3 id="heading-landing-page">Landing Page</h3>
<p>Create <code>app/page.tsx</code>. The landing page does two things: it renders navigation cards to the three main features, and it reads any tool output passed by ChatGPT to personalise the greeting. <code>useWidgetProps</code> is a hook from the Vercel ChatGPT Apps SDK that gives you access to the structured output from the MCP tool that opened this widget. If the user is signed in and <code>start_game</code> returned a <code>name</code> field in its <code>structuredContent</code>, the greeting will address them by name rather than showing the default copy.</p>
<pre><code class="language-typescript">"use client";

import Link from "next/link";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Play, Film, Trophy } from "lucide-react";
import { useWidgetProps } from "./hooks";

export default function Home() {
  const router = useRouter();

  // useWidgetProps reads the structured output that the MCP tool passed when
  // it opened this widget. If start_game returned a name in structuredContent,
  // we use it here to personalise the greeting. If not, we fall back to the
  // default tagline.
  const toolOutput = useWidgetProps&lt;{
    name?: string;
    result?: { structuredContent?: { name?: string } };
  }&gt;();

  const name = toolOutput?.result?.structuredContent?.name || toolOutput?.name;

  return (
    &lt;div className="min-h-screen bg-gradient-to-b from-slate-50 to-white dark:from-slate-900 dark:to-slate-800"&gt;
      &lt;main className="container mx-auto px-4 py-12 max-w-4xl"&gt;
        &lt;div className="text-center mb-8"&gt;
          &lt;h1 className="text-5xl font-extrabold text-slate-900 dark:text-white mb-3"&gt;Tetris&lt;/h1&gt;
          &lt;p className="text-lg text-slate-600 dark:text-slate-300"&gt;
            {name
              ? `Hi ${name}, ready to play?`
              : "Play classic Tetris in your browser — save replays and climb the leaderboard."}
          &lt;/p&gt;
        &lt;/div&gt;

        &lt;div className="grid md:grid-cols-3 gap-6 mb-12"&gt;
          &lt;Card className="hover:shadow-lg transition-shadow"&gt;
            &lt;CardHeader&gt;
              &lt;div className="flex items-center justify-center w-12 h-12 rounded-full bg-blue-100 dark:bg-blue-900/50 mb-4"&gt;
                &lt;Play className="w-6 h-6 text-blue-600 dark:text-blue-400" /&gt;
              &lt;/div&gt;
              &lt;CardTitle&gt;Play&lt;/CardTitle&gt;
              &lt;CardDescription&gt;Start a new Tetris game and save replays when you finish.&lt;/CardDescription&gt;
            &lt;/CardHeader&gt;
          &lt;/Card&gt;

          &lt;Card className="hover:shadow-lg transition-shadow"&gt;
            &lt;CardHeader&gt;
              &lt;div className="flex items-center justify-center w-12 h-12 rounded-full bg-green-100 dark:bg-green-900/50 mb-4"&gt;
                &lt;Film className="w-6 h-6 text-green-600 dark:text-green-400" /&gt;
              &lt;/div&gt;
              &lt;CardTitle&gt;Replays&lt;/CardTitle&gt;
              &lt;CardDescription&gt;View recent replays and replay your best runs.&lt;/CardDescription&gt;
            &lt;/CardHeader&gt;
          &lt;/Card&gt;

          &lt;Card className="hover:shadow-lg transition-shadow"&gt;
            &lt;CardHeader&gt;
              &lt;div className="flex items-center justify-center w-12 h-12 rounded-full bg-purple-100 dark:bg-purple-900/50 mb-4"&gt;
                &lt;Trophy className="w-6 h-6 text-purple-600 dark:text-purple-400" /&gt;
              &lt;/div&gt;
              &lt;CardTitle&gt;Leaderboard&lt;/CardTitle&gt;
              &lt;CardDescription&gt;See the top scores and compete for the highest rank.&lt;/CardDescription&gt;
            &lt;/CardHeader&gt;
          &lt;/Card&gt;
        &lt;/div&gt;

        &lt;div className="text-center space-y-6"&gt;
          &lt;div className="flex justify-center gap-4"&gt;
            &lt;Link href="/tetris/play"&gt;
              &lt;Button size="lg" className="gap-2"&gt;
                &lt;Play className="w-5 h-5" /&gt;
                Play Now
              &lt;/Button&gt;
            &lt;/Link&gt;
            &lt;Link href="/tetris/replays"&gt;
              &lt;Button size="lg" variant="outline" className="gap-2"&gt;
                &lt;Film className="w-5 h-5" /&gt;
                Replays
              &lt;/Button&gt;
            &lt;/Link&gt;
            &lt;Link href="/tetris/leaderboard"&gt;
              &lt;Button size="lg" variant="ghost" className="gap-2"&gt;
                &lt;Trophy className="w-5 h-5" /&gt;
                Leaderboard
              &lt;/Button&gt;
            &lt;/Link&gt;
          &lt;/div&gt;
        &lt;/div&gt;
      &lt;/main&gt;

      &lt;footer className="mt-16 py-6 border-t border-slate-200 dark:border-slate-800"&gt;
        &lt;div className="container mx-auto px-4 text-center text-slate-500 dark:text-slate-400"&gt;
          &lt;p&gt;Play Tetris — save replays and compete on the leaderboard&lt;/p&gt;
        &lt;/div&gt;
      &lt;/footer&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<h3 id="heading-game-page">Game Page</h3>
<p>Create <code>app/tetris/play/page.tsx</code>. This is a thin wrapper that mounts the <code>GameBoard</code> component. All game logic lives in the component itself; the page just provides the route and a heading.</p>
<pre><code class="language-typescript">"use client";

import React from "react";
import GameBoard from "@/components/tetris/GameBoard";

export default function PlayPage() {
  return (
    &lt;main className="p-6"&gt;
      &lt;h1 className="text-2xl font-bold mb-4"&gt;Play Tetris in ChatGPT&lt;/h1&gt;
      &lt;GameBoard /&gt;
    &lt;/main&gt;
  );
}
</code></pre>
<h3 id="heading-leaderboard-component">Leaderboard Component</h3>
<p>Create <code>components/tetris/Leaderboard.tsx</code>. Because Convex does not perform relational joins natively, this component uses two separate queries: one for the leaderboard entries and one to look up the user records for each entry. The results are joined client-side using a <code>Map</code>. Both queries are live subscriptions, so the table refreshes automatically for everyone viewing it the moment any player finishes a game.</p>
<pre><code class="language-typescript">"use client";

import React from 'react';
import { useQuery } from 'convex/react';
import { api } from '@/convex/_generated/api';

export default function Leaderboard() {
  const entries = useQuery(api.leaderboards.listTop, { limit: 20 }) || [];

  const userIds = entries.map((e: any) =&gt; e.userId).filter(Boolean);

  // "skip" is a Convex sentinel value that tells useQuery not to run the query
  // at all. Without it, passing an empty userIds array would fire a query that
  // returns nothing useful and produces a loading state on every initial render.
  const users = useQuery(
    api.users.getMultipleById,
    userIds.length &gt; 0 ? { userIds } : "skip"
  );

  // Build a lookup map so each entry can find its user in O(1) rather than
  // scanning the users array on every render.
  const userMap = new Map();
  if (users) {
    users.forEach((user: any) =&gt; {
      if (user) userMap.set(user._id, user);
    });
  }

  return (
    &lt;div className="max-w-2xl mx-auto p-4"&gt;
      &lt;h2 className="text-2xl font-bold mb-4"&gt;Leaderboard&lt;/h2&gt;
      &lt;ol className="list-decimal pl-6 space-y-2"&gt;
        {entries.map((e: any, idx: number) =&gt; {
          const user = userMap.get(e.userId);
          const displayName = user
            ? (user.displayName || `\({user.firstName || ''} \){user.lastName || ''}`.trim() || user.email)
            : 'Anonymous';

          return (
            &lt;li key={e._id} className="flex justify-between"&gt;
              &lt;div&gt;{displayName}&lt;/div&gt;
              &lt;div&gt;{e.score}&lt;/div&gt;
            &lt;/li&gt;
          );
        })}
      &lt;/ol&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<h3 id="heading-leaderboard-page">Leaderboard Page</h3>
<p>Create <code>app/tetris/leaderboard/page.tsx</code>. This is a server component that mounts the <code>Leaderboard</code> component at the <code>/tetris/leaderboard</code> route.</p>
<pre><code class="language-typescript">import React from "react";
import Leaderboard from "@/components/tetris/Leaderboard";

export default function LeaderboardPage() {
  return (
    &lt;main className="p-6"&gt;
      &lt;h1 className="text-2xl font-bold mb-4"&gt;Leaderboard&lt;/h1&gt;
      &lt;Leaderboard /&gt;
    &lt;/main&gt;
  );
}
</code></pre>
<h3 id="heading-replay-viewer-component">Replay Viewer Component</h3>
<p>Create <code>components/tetris/ReplayViewer.tsx</code>. This file contains two components: <code>ReplayPlayer</code>, which replays a single game from its action log, and <code>ReplayViewer</code>, which handles loading and selection. The key insight in replay playback is that rather than storing 20,000 board snapshots, you store a few hundred action codes and replay them at their original timestamps divided by the speed multiplier. A two-minute game replays in one minute at 2x speed simply by halving each inter-action delay.</p>
<h4 id="heading-replayplayer">ReplayPlayer</h4>
<p><code>ReplayPlayer</code> re-executes the stored action log against a fresh copy of the game engine, advancing state action by action at the original timings. A few design decisions are worth understanding before reading the code.</p>
<p><code>makeRng</code> implements a seeded pseudo-random number generator (PRNG) using a Mulberry32 algorithm. <code>Math.random()</code> cannot be seeded, so there is no way to reproduce the same piece sequence across two independent runs. By seeding the PRNG with the same value that was used during the original game, the replay generates the exact same piece order the player experienced.</p>
<p>The <code>boardRef</code>, <code>currentRef</code>, and <code>scoreRef</code> pattern mirrors the <code>useRef</code> approach in <code>GameBoard</code>. State updates trigger re-renders, but the <code>applyAction</code> function is called rapidly inside <code>scheduleNext</code> and needs to read the current board and piece synchronously between calls. Refs give it that direct access without waiting for a render cycle.</p>
<p><code>scheduleNext</code> is a recursive timeout function rather than a <code>setInterval</code>. Each call reads the gap between the current action's timestamp and the next one and schedules itself for exactly that delay divided by the playback speed. This reproduces the original timing precisely, including fast sequences and natural pauses, which a fixed interval cannot do.</p>
<pre><code class="language-typescript">"use client";

import React, { useEffect, useRef, useState } from 'react';
import { useQuery } from 'convex/react';
import { api } from '@/convex/_generated/api';
import { Id } from '@/convex/_generated/dataModel';

const WIDTH = 10;
const HEIGHT = 20;
const PIECES: Record&lt;string, number[][]&gt; = {
  I: [[1, 1, 1, 1]],
  O: [[1, 1], [1, 1]],
  T: [[0, 1, 0], [1, 1, 1]],
  S: [[0, 1, 1], [1, 1, 0]],
  Z: [[1, 1, 0], [0, 1, 1]],
  J: [[1, 0, 0], [1, 1, 1]],
  L: [[0, 0, 1], [1, 1, 1]],
};
const PIECE_COLORS: Record&lt;string, string&gt; = {
  I: "#00f0f0", O: "#f0f000", T: "#a000f0",
  S: "#00f000", Z: "#f00000", J: "#0000f0", L: "#f0a000",
};
const PIECE_TYPES = Object.keys(PIECES);

// Mulberry32 seeded PRNG. Math.random() cannot be seeded, so it cannot
// reproduce a specific piece sequence. This function returns a callable
// that produces the same sequence every time it is initialized with the
// same seed — matching the sequence the player saw during the original game.
function makeRng(seed: number) {
  let s = seed;
  return function () {
    s |= 0; s = s + 0x6D2B79F5 | 0;
    let t = Math.imul(s ^ s &gt;&gt;&gt; 15, 1 | s);
    t = t + Math.imul(t ^ t &gt;&gt;&gt; 7, 61 | t) ^ t;
    return ((t ^ t &gt;&gt;&gt; 14) &gt;&gt;&gt; 0) / 4294967296;
  };
}

function emptyBoard() {
  return Array.from({ length: HEIGHT }, () =&gt; Array.from({ length: WIDTH }, () =&gt; 0));
}

function rotate(shape: number[][]) {
  const h = shape.length, w = shape[0].length;
  const out = Array.from({ length: w }, () =&gt; Array.from({ length: h }, () =&gt; 0));
  for (let r = 0; r &lt; h; r++)
    for (let c = 0; c &lt; w; c++)
      out[c][h - 1 - r] = shape[r][c];
  return out;
}

function canPlace(board: number[][], shape: number[][], x: number, y: number) {
  for (let r = 0; r &lt; shape.length; r++)
    for (let c = 0; c &lt; shape[0].length; c++) {
      if (!shape[r][c]) continue;
      const br = y + r, bc = x + c;
      if (bc &lt; 0 || bc &gt;= WIDTH || br &lt; 0 || br &gt;= HEIGHT || board[br][bc]) return false;
    }
  return true;
}

function ReplayPlayer({ replay }: { replay: any }) {
  const seed = replay.game?.seed ?? 0;

  const [board, setBoard] = useState(emptyBoard());
  const [current, setCurrent] = useState&lt;any&gt;(null);
  const [score, setScore] = useState(0);
  const [level, setLevel] = useState(1);
  const [isPlaying, setIsPlaying] = useState(false);
  const [playbackSpeed, setPlaybackSpeed] = useState(1);

  // Refs hold the mutable game state that applyAction reads and writes
  // between render cycles. Using state here would cause applyAction to
  // close over stale values during rapid action sequences.
  const boardRef = useRef(emptyBoard());
  const currentRef = useRef&lt;any&gt;(null);
  const scoreRef = useRef(0);
  const actionIndexRef = useRef(0);
  const playbackRef = useRef&lt;ReturnType&lt;typeof setTimeout&gt; | null&gt;(null);
  const rngRef = useRef(makeRng(seed));

  function spawnPiece(brd: number[][]) {
    const type = PIECE_TYPES[Math.floor(rngRef.current() * PIECE_TYPES.length)];
    const shape = PIECES[type].map((r) =&gt; [...r]);
    const x = Math.floor((WIDTH - shape[0].length) / 2);
    if (!canPlace(brd, shape, x, 0)) return;
    const piece = { type, shape, x, y: 0 };
    currentRef.current = piece;
    setCurrent(piece);
  }

  function applyAction(action: { t: number; a: string; p?: any }) {
    const brd = boardRef.current;
    let cur = currentRef.current;

    if (action.a === "START") {
      const newBoard = emptyBoard();
      boardRef.current = newBoard;
      scoreRef.current = 0;
      rngRef.current = makeRng(seed);
      setBoard(newBoard);
      setScore(0);
      setLevel(1);
      spawnPiece(newBoard);
      return;
    }

    if (!cur) return;

    if (action.a === "L" &amp;&amp; canPlace(brd, cur.shape, cur.x - 1, cur.y)) {
      cur = { ...cur, x: cur.x - 1 };
    } else if (action.a === "R" &amp;&amp; canPlace(brd, cur.shape, cur.x + 1, cur.y)) {
      cur = { ...cur, x: cur.x + 1 };
    } else if (action.a === "D" &amp;&amp; canPlace(brd, cur.shape, cur.x, cur.y + 1)) {
      cur = { ...cur, y: cur.y + 1 };
    } else if (action.a === "ROT") {
      const rotated = rotate(cur.shape);
      if (canPlace(brd, rotated, cur.x, cur.y)) cur = { ...cur, shape: rotated };
    } else if (action.a === "HD") {
      // Hard drop: find the lowest valid y position by incrementing until
      // canPlace fails, then lock the piece there immediately. Unlike a
      // soft drop (action "D"), hard drop merges the piece into the board
      // in a single step, clears any completed lines, and spawns the next piece.
      let dropY = cur.y;
      while (canPlace(brd, cur.shape, cur.x, dropY + 1)) dropY++;
      cur = { ...cur, y: dropY };

      const copy = brd.map((r) =&gt; [...r]);
      for (let r = 0; r &lt; cur.shape.length; r++)
        for (let c = 0; c &lt; cur.shape[0].length; c++)
          if (cur.shape[r][c]) copy[cur.y + r][cur.x + c] = PIECE_TYPES.indexOf(cur.type) + 1;

      const out: number[][] = [];
      let cleared = 0;
      for (let r = 0; r &lt; HEIGHT; r++) {
        if (copy[r].every((v) =&gt; v !== 0)) cleared++;
        else out.push(copy[r]);
      }
      while (out.length &lt; HEIGHT) out.unshift(Array.from({ length: WIDTH }, () =&gt; 0));

      if (cleared &gt; 0) {
        scoreRef.current += cleared * 100;
        setScore(scoreRef.current);
        setLevel(Math.floor(scoreRef.current / 1000) + 1);
      }

      boardRef.current = out;
      setBoard([...out]);
      currentRef.current = null;
      setCurrent(null);
      spawnPiece(out);
      return;
    }

    currentRef.current = cur;
    setCurrent({ ...cur });
  }

  function startPlayback() {
    if (!replay?.actions?.length) return;
    actionIndexRef.current = 0;
    boardRef.current = emptyBoard();
    rngRef.current = makeRng(seed);
    setBoard(emptyBoard());
    setCurrent(null);
    setScore(0);
    setLevel(1);
    setIsPlaying(true);

    // scheduleNext is a recursive timeout rather than a setInterval because
    // each action has a different delay: the gap between its timestamp and
    // the next action's timestamp, divided by playback speed. A fixed interval
    // would not reproduce natural timing variations in the original game.
    function scheduleNext() {
      const actions = replay.actions;
      if (actionIndexRef.current &gt;= actions.length) {
        setIsPlaying(false);
        return;
      }
      const curr = actions[actionIndexRef.current];
      const next = actions[actionIndexRef.current + 1];
      const delay = next ? (next.t - curr.t) / playbackSpeed : 500 / playbackSpeed;
      applyAction(curr);
      actionIndexRef.current++;
      playbackRef.current = setTimeout(scheduleNext, Math.max(16, delay));
    }

    scheduleNext();
  }

  function stopPlayback() {
    if (playbackRef.current) clearTimeout(playbackRef.current);
    setIsPlaying(false);
  }

  useEffect(() =&gt; () =&gt; { if (playbackRef.current) clearTimeout(playbackRef.current); }, []);

  const display = board.map((row, r) =&gt;
    row.map((cell, c) =&gt; {
      if (current &amp;&amp; r &gt;= current.y &amp;&amp; r &lt; current.y + current.shape.length) {
        const sr = r - current.y;
        const sc = c - current.x;
        if (sc &gt;= 0 &amp;&amp; sc &lt; current.shape[0].length &amp;&amp; current.shape[sr]?.[sc])
          return PIECE_TYPES.indexOf(current.type) + 10;
      }
      return cell;
    })
  );

  const cellPx = 20;
  const displayName = replay.user?.displayName ?? replay.user?.firstName ?? replay.user?.email ?? "Anonymous";

  return (
    &lt;div className="flex flex-col items-center gap-4 p-4 bg-slate-900 text-white"&gt;
      &lt;div className="text-lg font-bold text-cyan-400"&gt;{displayName}&lt;/div&gt;
      &lt;div className="text-slate-400 text-sm flex gap-4"&gt;
        &lt;span&gt;Score: {replay.game?.score?.toLocaleString() ?? "?"}&lt;/span&gt;
        &lt;span&gt;Level: {replay.game?.level ?? "?"}&lt;/span&gt;
        &lt;span&gt;Lines: {replay.game?.linesCleared ?? "?"}&lt;/span&gt;
        &lt;span&gt;Duration: {Math.round((replay.durationMs ?? 0) / 1000)}s&lt;/span&gt;
      &lt;/div&gt;

      &lt;div
        className="grid border border-slate-600"
        style={{ gridTemplateColumns: `repeat(\({WIDTH}, \){cellPx}px)` }}
      &gt;
        {display.flatMap((row, r) =&gt;
          row.map((cell, c) =&gt; {
            const colorIdx = cell &gt;= 10 ? cell - 10 : cell &gt; 0 ? cell - 1 : -1;
            return (
              &lt;div
                key={`\({r}-\){c}`}
                style={{
                  width: cellPx,
                  height: cellPx,
                  background: colorIdx &gt;= 0 ? PIECE_COLORS[PIECE_TYPES[colorIdx]] : "#0f172a",
                  border: "1px solid rgba(100,116,139,0.2)",
                }}
              /&gt;
            );
          })
        )}
      &lt;/div&gt;

      &lt;div className="flex gap-6 text-sm"&gt;
        &lt;div className="text-center"&gt;
          &lt;div className="text-slate-400"&gt;Score&lt;/div&gt;
          &lt;div className="font-bold text-cyan-400"&gt;{score.toLocaleString()}&lt;/div&gt;
        &lt;/div&gt;
        &lt;div className="text-center"&gt;
          &lt;div className="text-slate-400"&gt;Level&lt;/div&gt;
          &lt;div className="font-bold text-purple-400"&gt;{level}&lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div className="flex gap-2 items-center"&gt;
        &lt;button
          onClick={isPlaying ? stopPlayback : startPlayback}
          className="px-4 py-2 bg-cyan-600 hover:bg-cyan-500 rounded-lg font-medium transition-colors"
        &gt;
          {isPlaying ? "Stop" : "Play Replay"}
        &lt;/button&gt;
        &lt;select
          title="play-back"
          value={playbackSpeed}
          onChange={(e) =&gt; setPlaybackSpeed(Number(e.target.value))}
          className="px-3 py-2 bg-slate-700 rounded-lg"
        &gt;
          &lt;option value={0.5}&gt;0.5x&lt;/option&gt;
          &lt;option value={1}&gt;1x&lt;/option&gt;
          &lt;option value={2}&gt;2x&lt;/option&gt;
          &lt;option value={4}&gt;4x&lt;/option&gt;
        &lt;/select&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<h4 id="heading-replayviewer">ReplayViewer</h4>
<p><code>ReplayViewer</code> handles the outer shell: loading a specific replay by ID when one is provided, fetching the recent replay list when not, and managing which replay is selected for playback. Once a replay is selected, it hands off to <code>ReplayPlayer</code>.</p>
<pre><code class="language-typescript">export default function ReplayViewer({ replayId }: { replayId?: Id&lt;"replays"&gt; | string }) {
  const replay = useQuery(
    api.replays.getReplay,
    replayId ? { replayId: replayId as Id&lt;"replays"&gt; } : "skip"
  );
  const recent = useQuery(api.replays.getRecentReplaysWithDetails, {});

  const [selected, setSelected] = useState&lt;any | null&gt;(null);

  useEffect(() =&gt; {
    if (replay) setSelected(replay);
  }, [replay]);

  if (replayId &amp;&amp; !replay) {
    return &lt;div className="p-4"&gt;Loading replay...&lt;/div&gt;;
  }

  if (!replayId &amp;&amp; !recent) {
    return &lt;div className="p-4"&gt;Loading recent replays...&lt;/div&gt;;
  }

  if (!selected) {
    if (recent?.length === 0) {
      return &lt;div className="p-4"&gt;No recent replays available.&lt;/div&gt;;
    }
    return (
      &lt;div className="max-w-lg mx-auto p-4"&gt;
        &lt;h3 className="font-bold mb-2"&gt;Recent Replays&lt;/h3&gt;
        &lt;ul className="space-y-2"&gt;
          {recent?.map((r) =&gt; {
            const name = r.user?.displayName ?? r.user?.firstName ?? r.user?.email ?? "Anonymous";
            return (
              &lt;li key={r._id}&gt;
                &lt;button
                  className="underline text-blue-600"
                  onClick={() =&gt; setSelected(r)}
                &gt;
                  {name} | score {r.game?.score?.toLocaleString() ?? "?"} · {r.actions?.length ?? 0} actions
                &lt;/button&gt;
              &lt;/li&gt;
            );
          })}
        &lt;/ul&gt;
      &lt;/div&gt;
    );
  }

  return (
    &lt;div className="min-h-screen bg-slate-900"&gt;
      &lt;div className="text-center pt-4"&gt;
        &lt;button
          className="text-cyan-400 hover:underline text-sm"
          onClick={() =&gt; setSelected(null)}
        &gt;
          Back to replay list
        &lt;/button&gt;
      &lt;/div&gt;
      &lt;ReplayPlayer replay={selected} /&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<h3 id="heading-replays-page">Replays Page</h3>
<p>Create <code>app/tetris/replays/page.tsx</code>. This mounts <code>ReplayViewer</code> without a <code>replayId</code>, which causes the component to display the recent replays list rather than jumping straight to a specific game.</p>
<pre><code class="language-typescript">import React from "react";
import ReplayViewer from "@/components/tetris/ReplayViewer";

export default function ReplaysPage() {
  return (
    &lt;main className="p-6"&gt;
      &lt;h1 className="text-2xl font-bold mb-4"&gt;Replays&lt;/h1&gt;
      &lt;ReplayViewer /&gt;
    &lt;/main&gt;
  );
}
</code></pre>
<h3 id="heading-verify-all-routes">Verify All Routes</h3>
<p>Start the dev server and confirm each route loads:</p>
<pre><code class="language-shell">pnpm dev
</code></pre>
<p>Then check each URL:</p>
<pre><code class="language-shell">http://localhost:3000                            Landing page
http://localhost:3000/tetris/play                Game (no gameId, anonymous start)
http://localhost:3000/tetris/leaderboard         Live leaderboard
http://localhost:3000/tetris/replays             Replay list
http://localhost:3000/tetris/replays?replayId=x  Viewer (shows "not found" until a real ID exists)
http://localhost:3000/mcp-docs                   MCP documentation
</code></pre>
<p>If all six routes load without errors, the supporting features are wired up correctly.</p>
<h2 id="heading-deploying-to-vercel">Deploying to Vercel</h2>
<p>Local development is working. This section gets everything running in production with the correct environment variables, Convex deployment, and ChatGPT connector registration.</p>
<h3 id="heading-pre-deployment-checklist">Pre-Deployment Checklist</h3>
<p>Before deploying, verify these items locally:</p>
<pre><code class="language-shell"># 1. Build succeeds without errors
pnpm build

# 2. All environment variables are present
cat .env.local

# 3. Convex dev is running and schema is synced
pnpm convex dev

# 4. MCP route responds correctly
curl -X POST http://localhost:3000/mcp \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'

# 5. OAuth discovery endpoint is accessible
curl http://localhost:3000/mcp/.well-known/oauth-protected-resource
</code></pre>
<p>Fix any build errors before continuing. Type errors and missing imports will cause the Vercel build to fail even if local dev works.</p>
<h3 id="heading-deploy-convex-to-production">Deploy Convex to Production</h3>
<p>Convex has separate dev and production environments. Your dev deployment runs locally, so you need a separate production deployment for Vercel:</p>
<pre><code class="language-bash">pnpm convex deploy
</code></pre>
<p>This command pushes your schema and all functions to a production Convex deployment. After it completes, copy the production URL from the output. Production URLs use your project name rather than a random animal name, so they look like <code>https://your-project-name.convex.cloud</code> rather than the <code>https://happy-animal-123.convex.cloud</code> format you see in development.</p>
<h3 id="heading-initial-vercel-deployment">Initial Vercel Deployment</h3>
<pre><code class="language-bash">vercel --prod
</code></pre>
<p>Vercel will ask which project to link to. Select the project you linked in Section 3. After deployment completes, copy your production URL, which looks something like <code>https://tetris-chatgpt-app.vercel.app</code>.</p>
<p>Note that this first deployment runs without your production environment variables. You will redeploy after adding them in the next step, which is why two <code>vercel --prod</code> commands appear in this section.</p>
<h3 id="heading-configure-production-environment-variables">Configure Production Environment Variables</h3>
<p>Your production deployment needs different values for several variables. Go to the Vercel dashboard, open your project, navigate to Settings, then Environment Variables, and add all of the following:</p>
<pre><code class="language-bash"># Convex — use production values from pnpm convex deploy output
CONVEX_DEPLOYMENT=prod:happy-animal-123
NEXT_PUBLIC_CONVEX_URL=https://happy-animal-123.convex.cloud
NEXT_PUBLIC_CONVEX_HTTP_URL=https://happy-animal-123.convex.site

# Kinde — same values as local
KINDE_ISSUER=https://yourcompany.kinde.com
KINDE_CLIENT_ID=your-client-id
KINDE_CLIENT_SECRET=your-client-secret

# Vercel — use your production Vercel URL
VERCEL_PROJECT_PRODUCTION_URL=https://tetris-chatgptapp.com
VERCEL_BRANCH_URL=https://tetris-chatgpt-app.vercel.app
VERCEL_URL=https://tetris-chatgpt-app.vercel.app
VERCEL_ENV=production
NODE_ENV=production

# MCP — use your production Vercel URL
MCP_AUDIENCE=https://tetris-chatgpt-app.vercel.app/mcp
MCP_RESOURCE=https://tetris-chatgpt-app.vercel.app
MCP_DOC_URL=https://tetris-chatgpt-app.vercel.app/mcp-docs
</code></pre>
<p>Or set them via CLI:</p>
<pre><code class="language-bash">vercel env add CONVEX_DEPLOYMENT production
vercel env add NEXT_PUBLIC_CONVEX_URL production
vercel env add KINDE_ISSUER production
vercel env add KINDE_CLIENT_ID production
vercel env add KINDE_CLIENT_SECRET production
vercel env add VERCEL_PROJECT_PRODUCTION_URL production
vercel env add VERCEL_BRANCH_URL production
vercel env add VERCEL_URL production
vercel env add VERCEL_ENV production
vercel env add NODE_ENV production
vercel env add MCP_AUDIENCE production
vercel env add MCP_RESOURCE production
vercel env add MCP_DOC_URL production
</code></pre>
<h3 id="heading-update-kinde-callback-urls">Update Kinde Callback URLs</h3>
<p>Go to <a href="https://kinde.com?utm_source=fcc&amp;utm_medium=content&amp;utm_campaign=shola&amp;campaignid=chatgptapp&amp;network=&amp;adgroup=&amp;keyword=&amp;matchtype=&amp;creative=3&amp;device=&amp;adposition=">Kinde</a>, open your application, navigate to Settings, then Allowed callback URLs, and add your production URLs:</p>
<pre><code class="language-plaintext">https://tetris-chatgpt-app.vercel.app/api/auth/callback
https://chatgpt.com/connector_platform_oauth_redirect
</code></pre>
<p>And in Allowed logout redirect URLs:</p>
<pre><code class="language-plaintext">https://tetris-chatgpt-app.vercel.app
https://chatgpt.com
</code></pre>
<p>The <code>chatgpt.com</code> callback URL is what ChatGPT uses after OAuth completes. Without it, Kinde will reject the redirect and authentication will fail silently.</p>
<h3 id="heading-redeploy-with-production-variables">Redeploy with Production Variables</h3>
<p>After setting environment variables, trigger a new deployment so the values take effect:</p>
<pre><code class="language-bash">vercel --prod
</code></pre>
<p>Or push a commit to your main branch if you have connected GitHub.</p>
<h3 id="heading-verify-production-endpoints">Verify Production Endpoints</h3>
<p>Once deployed, test every critical endpoint:</p>
<pre><code class="language-shell">PROD_URL="https://tetris-chatgpt-app.vercel.app"

# MCP tools list
curl -X POST $PROD_URL/mcp \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'

# OAuth discovery
curl $PROD_URL/mcp/.well-known/oauth-protected-resource

# Landing page loads
curl -I $PROD_URL
</code></pre>
<p>All three should return 200 status codes with the expected content.</p>
<h2 id="heading-registering-with-chatgpt">Registering with ChatGPT</h2>
<p>This is the step where your app becomes playable inside ChatGPT. You will enable Developer Mode, create the connector, and walk through the OAuth flow for the first time.</p>
<h3 id="heading-enable-developer-mode">Enable Developer Mode</h3>
<p>Go to Settings, then Connectors, then Advanced, and enable Developer Mode.</p>
<p><strong>Important:</strong> Enabling Developer Mode automatically disables Memory. If you rely on ChatGPT's Memory feature, note this tradeoff before proceeding. Developer Mode is required for custom MCP connectors and is available on Plus, Pro, and Team plans.</p>
<h3 id="heading-create-the-connector">Create the Connector</h3>
<p>Go to Settings, then Connectors, then Create, and fill in the form:</p>
<pre><code class="language-plaintext">Name:           Tetris
Description:    Play Tetris inside ChatGPT with real-time leaderboards
MCP Server URL: https://tetris-chatgpt-app.vercel.app/mcp
Authentication: OAuth
</code></pre>
<p>Check "I trust this application" and click Create.</p>
<h3 id="heading-how-the-oauth-flow-works">How the OAuth Flow Works</h3>
<p>Because your MCP server exposes the <code>/.well-known/oauth-protected-resource</code> endpoint and your tools declare <code>securitySchemes</code>, ChatGPT handles the OAuth flow automatically when a user invokes an authenticated tool for the first time.</p>
<p>The user gets redirected to your Kinde login page, authenticates, approves the requested scopes, and ChatGPT exchanges the authorization code for a token. From that point on, ChatGPT attaches <code>Authorization: Bearer &lt;token&gt;</code> to every MCP request, which is what your <code>extractTokenFromArgs</code> function reads.</p>
<p>Anonymous tools like <code>get_leaderboard</code> and <code>view_replay</code> work immediately without sign-in. Authenticated tools like <code>start_game</code> and <code>finish_game</code> trigger the sign-in flow on first use if the user is not already linked.</p>
<h3 id="heading-test-it">Test It</h3>
<pre><code class="language-plaintext">You: "Start a Tetris game"
ChatGPT: [OAuth prompt appears if not signed in, sign in, game widget renders]

You: "Show me the leaderboard"
ChatGPT: [calls get_leaderboard, renders leaderboard widget immediately, no sign-in needed]
</code></pre>
<p>If ChatGPT shows an error instead of a widget, check the Vercel function logs: Dashboard, your project, Functions, then click any invocation to see the full request and response.</p>
<p>The two most common issues are the <code>chatgpt.com/connector_platform_oauth_redirect</code> callback URL not being in your Kinde allowlist, and a missing <code>code_challenge_methods_supported: ["S256"]</code> field in your Kinde metadata. The <code>S256</code> value refers to the PKCE (Proof Key for Code Exchange) challenge method, which ChatGPT requires for its OAuth flow. Kinde includes this by default, but if you are using a custom OAuth provider, verify it is present in your <code>/.well-known/openid-configuration</code> response.</p>
<h2 id="heading-finishing-up">Finishing Up</h2>
<h3 id="heading-custom-domain-optional">Custom Domain (Optional)</h3>
<p>If you have a domain, add it in Vercel:</p>
<pre><code class="language-bash">vercel domains add yourdomain.com
</code></pre>
<p>Then update all environment variables and Kinde callback URLs to use the custom domain. The MCP connector registration in ChatGPT will also need updating to the new URL.</p>
<h3 id="heading-environment-variable-reference">Environment Variable Reference</h3>
<p>The complete variable list with descriptions, to help diagnose configuration issues:</p>
<pre><code class="language-shell"># Convex
CONVEX_DEPLOYMENT          # Deployment name (dev:... or prod:...)
NEXT_PUBLIC_CONVEX_URL     # Full Convex URL (must start with https://)

# Kinde — all from your Kinde application settings page
KINDE_ISSUER               # Your Kinde domain (no trailing slash)
KINDE_CLIENT_ID            # Application client ID
KINDE_CLIENT_SECRET        # Application client secret

# MCP — all must use your production URL in production
MCP_AUDIENCE               # Full URL to /mcp route
MCP_RESOURCE               # Root URL of your deployment
MCP_DOC_URL                # URL to /mcp-docs page
</code></pre>
<p>The most common misconfiguration is using <code>localhost</code> values in production. <code>MCP_AUDIENCE</code> must match the <code>resource</code> field in your OAuth discovery endpoint. If these do not match, ChatGPT cannot complete the OAuth flow.</p>
<h3 id="heading-production-vs-preview-deployments">Production vs. Preview Deployments</h3>
<p>Vercel creates a unique URL for every pull request (for example, <code>tetris-chatgpt-app-git-feature-branch.vercel.app</code>). These preview deployments use the same environment variables, but <code>MCP_AUDIENCE</code> is hardcoded to your production URL, so OAuth will not work in preview by default.</p>
<p>For preview deployments that need working auth, use the <code>VERCEL_BRANCH_URL</code> variable, which the <code>baseURL</code> helper in <code>lib/baseURL.ts</code> already handles:</p>
<pre><code class="language-typescript">// lib/baseURL.ts — resolves the correct base URL for each deployment type
export const baseURL =
  process.env.NODE_ENV === "development"
    ? "http://localhost:3000"
    : "https://" +
      (process.env.VERCEL_ENV === "production"
        ? process.env.VERCEL_PROJECT_PRODUCTION_URL
        : process.env.VERCEL_BRANCH_URL || process.env.VERCEL_URL);
</code></pre>
<p>The base URL resolves correctly for each deployment automatically. The remaining issue is that Kinde's allowed callback URLs do not include preview URLs. Add <code>https://*.vercel.app/api/auth/callback</code> as a wildcard in Kinde's settings if you want preview auth to work.</p>
<h3 id="heading-monitoring-and-logs">Monitoring and Logs</h3>
<p>Vercel provides function-level logs accessible in the dashboard.</p>
<p><strong>Build logs</strong> cover compilation errors, missing modules, and type errors. Check these first if a deployment fails.</p>
<p><strong>Function logs</strong> cover runtime errors, timeouts, and unhandled exceptions. Each invocation is listed individually so you can inspect the exact request and response that caused an error.</p>
<p><strong>Edge Network logs</strong> cover CORS issues and header problems. Check these if requests are being blocked before they reach your functions.</p>
<p>For Convex issues, the Convex dashboard at <code>dashboard.convex.dev</code> shows real-time function logs. Every mutation and query is logged with its arguments, return values, and execution time.</p>
<h2 id="heading-troubleshooting">Troubleshooting</h2>
<p>Even with everything configured correctly, you will hit issues. Here are the most common ones and how to fix them.</p>
<h3 id="heading-chatgpt-shows-action-not-found-or-doesnt-recognize-your-tools">ChatGPT shows "action not found" or doesn't recognize your tools</h3>
<p>Developer Mode is not enabled. Go to Settings → Connectors → Advanced and enable Developer Mode. This is required for custom MCP connectors and is only available on Plus, Pro, and Team plans.</p>
<p>If Developer Mode is already on, go to Settings → Connectors, find your connector in the list, and click the Refresh icon next to it. ChatGPT caches your MCP manifest and does not automatically discover new tools. A manual refresh is required whenever your tool list changes.</p>
<h3 id="heading-widget-renders-blank-or-shows-a-white-iframe">Widget renders blank or shows a white iframe</h3>
<p>This is almost always a CORS issue. Verify your <code>next.config.ts</code> has the permissive headers configured. The three required headers belong inside the <code>headers()</code> function in your Next.js config:</p>
<pre><code class="language-typescript">// next.config.ts
const nextConfig = {
  async headers() {
    return [
      {
        source: "/(.*)",
        headers: [
          { key: "Access-Control-Allow-Origin", value: "*" },
          { key: "Access-Control-Allow-Methods", value: "GET,POST,PUT,DELETE,OPTIONS" },
          { key: "Access-Control-Allow-Headers", value: "*" },
        ],
      },
    ];
  },
};
</code></pre>
<p>Also check that <code>MCP_RESOURCE</code> in your Vercel environment variables matches the exact URL ChatGPT is using to reach your app, including <code>https://</code> and no trailing slash. A mismatch causes the OAuth discovery endpoint to return a <code>resource</code> URL that does not match the incoming request, which silently breaks widget rendering.</p>
<h3 id="heading-oauth-flow-never-completes-stuck-on-redirect">OAuth flow never completes (stuck on redirect)</h3>
<p>The most common cause is a missing callback URL in Kinde. Go to your <a href="https://kinde.com?utm_source=fcc&amp;utm_medium=content&amp;utm_campaign=shola&amp;campaignid=chatgptapp&amp;network=&amp;adgroup=&amp;keyword=&amp;matchtype=&amp;creative=3&amp;device=&amp;adposition=">Kinde application</a> settings and confirm both of these are in your Allowed Callback URLs:</p>
<pre><code class="language-plaintext">https://your-app.vercel.app/api/auth/callback
https://chatgpt.com/connector_platform_oauth_redirect
</code></pre>
<p>The second URL is what ChatGPT uses to receive the authorization code after the user signs in. Without it, Kinde rejects the redirect and the user sees an error page instead of returning to ChatGPT.</p>
<p>If the callback URLs are correct and the flow still fails, check your <code>MCP_AUDIENCE</code> environment variable. It must exactly match the <code>resource</code> field in your <code>/.well-known/oauth-protected-resource</code> response. ChatGPT echoes this value as the <code>resource</code> parameter throughout the OAuth flow, and Kinde embeds it in the token's <code>aud</code> (audience) claim. When your tool handler calls <code>validateKindeToken</code>, it checks that <code>aud</code> matches <code>MCP_AUDIENCE</code>. If they differ by even a trailing slash, validation fails silently and every authenticated tool call returns an auth error.</p>
<h3 id="heading-score-is-not-saving-finishgame-errors">Score is not saving (<code>finish_game</code> errors)</h3>
<p>This usually means <code>finish_game</code> was called before <code>start_game</code> returned a <code>gameId</code>. To confirm, add a log line to the <code>start()</code> function in <code>GameBoard.tsx</code> after the <code>callTool</code> response arrives:</p>
<pre><code class="language-typescript">const gameIdToUse = (toolRes as any)?.structuredContent?.gameId;
console.log("Game ID captured:", gameIdToUse); // Add this line
if (gameIdToUse) setGameId(gameIdToUse);
</code></pre>
<p>If the log line is missing from the console, <code>start_game</code> either failed or the <code>structuredContent.gameId</code> field was not returned. Check your Vercel function logs for the <code>start_game</code> invocation and confirm your MCP route handler is returning both fields:</p>
<pre><code class="language-typescript">return {
  content: [{ type: "text", text: widget }],
  structuredContent: { gameId: String(gameId) },
};
</code></pre>
<p>Both fields are required. If <code>structuredContent</code> is absent, <code>callTool</code> returns no game ID and <code>finish_game</code> has nothing to save.</p>
<h3 id="heading-replay-plays-wrong-pieces-board-diverges-immediately">Replay plays wrong pieces (board diverges immediately)</h3>
<p>The seeded RNG in <code>ReplayViewer</code> must start from the same position as the original game. Confirm that the <code>START</code> action is the first entry in <code>actionsRef.current</code>. If it is missing, <code>ReplayPlayer</code> never calls <code>spawnPiece</code> with a freshly seeded RNG and the first piece spawns at a different position in the random sequence than the player originally saw. The fix is to ensure <code>actionsRef.current.push({ t: Date.now(), a: "START" })</code> runs at the very beginning of the <code>start()</code> function, before any other actions are recorded.</p>
<h3 id="heading-convex-mutations-throw-argument-validation-failed">Convex mutations throw "argument validation failed"</h3>
<p>Your function arguments do not match the schema defined in <code>convex/schema.ts</code>. Open <code>dashboard.convex.dev</code>, go to Logs, and find the failed mutation. The error message will name the exact field that failed.</p>
<p>The two most common mismatches are passing a plain string where a <code>v.id("games")</code> typed ID is expected, and sending <code>undefined</code> for a required field. Both are fixed by checking the mutation's <code>args</code> definition in the relevant <code>convex/</code> file and ensuring the values you pass match the declared types exactly.</p>
<h3 id="heading-mcp-route-returns-500-on-every-request">MCP route returns 500 on every request</h3>
<p>A 500 on every request almost always means a missing environment variable. A missing <code>NEXT_PUBLIC_CONVEX_URL</code> causes <code>getConvexClient()</code> to throw on every invocation before any tool logic runs.</p>
<p>Go to your Vercel project → Settings → Environment Variables and verify every variable from the deployment checklist is present and scoped to the <strong>Production</strong> environment. Variables added only to Preview or Development do not apply to production deployments and will not appear in your function's <code>process.env</code>.</p>
<h2 id="heading-final-data-flow">Final Data Flow</h2>
<p>Here is how a complete game session flows through the system, from user input to ChatGPT rendering:</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/62cab1b3e62bf98e0fb0a38f/d8a5c003-419c-42b7-a282-6088da230f56.svg" alt="ChatGPT Tetris App: Final Data Flow" style="display:block;margin:0 auto" width="1137.9609375" height="1832" loading="lazy">

<p>Every step that touches Convex is transactional. If <code>finishGame</code> fails partway through, none of the writes commit and the game stays in <code>active</code> status, no replay is created, and the leaderboard is not updated. This prevents orphaned records and inconsistent state.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>You've built a production Tetris game that runs inside ChatGPT with real-time leaderboards, replay recording, and Kinde OAuth authentication, all delivered through the MCP protocol without the user ever leaving the chat.</p>
<p>The architecture is the important part. The game is just the demonstration. What you actually built is a pattern: an MCP server that authenticates users, stores data in a real-time database, and renders interactive widgets inside ChatGPT.</p>
<p>Swap the game for a task manager, a data dashboard, a booking system, or anything that benefits from a conversational interface and the Convex backend, the Kinde auth flow, and the MCP tool registration all carry over unchanged.</p>
<p>ChatGPT becomes the interface. Your app becomes the capability behind it.</p>
<h3 id="heading-next-steps">Next Steps</h3>
<p>Some ideas for where to take this further:</p>
<ul>
<li><p><strong>Mobile gestures:</strong> add touch event handlers to <code>GameBoard.tsx</code> so the game is playable on ChatGPT's iOS and Android apps, where keyboard input doesn't work. Tap to rotate, swipe left or right to move, swipe down to soft drop.</p>
</li>
<li><p><strong>AI opponent:</strong> implement the Pierre Dellacherie algorithm as a demo mode which is useful for the leaderboard page when no one is actively playing, or as an optional AI assist toggle during a real game.</p>
</li>
<li><p><strong>RBAC:</strong> add admin vs. player roles using Kinde's built-in permission system to let admins moderate the leaderboard, delete replays, or ban users.</p>
</li>
<li><p><strong>Submit to the ChatGPT app directory:</strong> once you have tested with real users, submit your connector so people can discover it without manually entering your MCP URL. See the <a href="https://developers.openai.com/apps-sdk/app-submission-guidelines">submission guidelines</a>.</p>
</li>
<li><p><strong>Multiplayer:</strong> Convex's real-time subscriptions make it well-suited for competitive modes. Two players subscribe to the same game document and see each other's board update live with no WebSocket boilerplate required.</p>
</li>
</ul>
<h3 id="heading-resources">Resources</h3>
<p><strong>Source code</strong></p>
<ul>
<li>Complete source code for this tutorial: <a href="https://github.com/sholajegede/chatgpt-tetris">GitHub repository</a>. If it helped you, consider giving it a star</li>
</ul>
<p><strong>Core documentation</strong></p>
<ul>
<li><p><a href="https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization">MCP Protocol Specification</a>: the full MCP authorization spec ChatGPT implements</p>
</li>
<li><p><a href="https://developers.openai.com/apps-sdk">Vercel ChatGPT Apps SDK</a>: official OpenAI documentation for building apps inside ChatGPT</p>
</li>
<li><p><a href="https://developers.openai.com/apps-sdk/deploy/connect-chatgpt">ChatGPT connector registration</a>: how to connect, test, and publish your app</p>
</li>
<li><p><a href="https://developers.openai.com/apps-sdk/build/auth">Apps SDK authentication guide</a>: OAuth 2.1 flow, security schemes, and token verification</p>
</li>
</ul>
<p><strong>Services used</strong></p>
<ul>
<li><p><a href="https://docs.convex.dev/">Convex documentation</a>:real-time database, schema, mutations, queries</p>
</li>
<li><p><a href="https://docs.kinde.com/">Kinde documentation</a>: OAuth, JWT validation, user management</p>
</li>
<li><p><a href="https://vercel.com/docs">Vercel documentation</a>: deployment, environment variables, function logs</p>
</li>
</ul>
<p><strong>Debugging tools</strong></p>
<ul>
<li><p><a href="https://modelcontextprotocol.io/docs/tools/inspector">MCP Inspector</a>: walk through OAuth steps and inspect live MCP requests locally before deploying</p>
</li>
<li><p><a href="https://dashboard.convex.dev/">Convex dashboard</a>: real-time function logs, data browser, and schema viewer</p>
</li>
<li><p><a href="https://openai.com/chatgpt-connectors.json">OpenAI egress IPs</a>: allowlist these if you want to restrict MCP access to ChatGPT only</p>
</li>
</ul>
<p><strong>Further reading</strong></p>
<ul>
<li><p><a href="https://imake.ninja/el-tetris-an-improvement-on-pierre-dellacheries-algorithm/">Pierre Dellacherie algorithm</a>: the Tetris AI heuristic referenced in Next Steps</p>
</li>
<li><p><a href="https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization">MCP authorization spec</a>: PKCE, dynamic client registration, resource metadata</p>
</li>
</ul>
<p>If this tutorial was useful, feel free to share it with others who might benefit. I’d really appreciate your thoughts, you can mention me on X at <a href="https://x.com/wani_shola">@wani_shola</a> or <a href="https://linkedin.com/in/sholajegede">connect with me on LinkedIn</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a To-Do List MCP Server Using TypeScript – with Auth, Database, and Billing ]]>
                </title>
                <description>
                    <![CDATA[ In this tutorial, you’ll build a To-Do list MCP server using TypeScript. You’ll learn how to implement authentication, persistence, and billing, to make the server robust and functional for real users. By the end, you’ll have a working MCP server tha... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-to-do-list-mcp-server-using-typescript/</link>
                <guid isPermaLink="false">68f93792b7f64a597dc407f9</guid>
                
                    <category>
                        <![CDATA[ mcp ]]>
                    </category>
                
                    <category>
                        <![CDATA[ mcp server ]]>
                    </category>
                
                    <category>
                        <![CDATA[ TypeScript ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Shola Jegede ]]>
                </dc:creator>
                <pubDate>Wed, 22 Oct 2025 19:59:14 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1761162036666/77972b3f-9dc8-404f-b40d-fb70ee73e2a5.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>In this tutorial, you’ll build a To-Do list MCP server using TypeScript. You’ll learn how to implement authentication, persistence, and billing, to make the server robust and functional for real users.</p>
<p>By the end, you’ll have a working MCP server that:</p>
<ul>
<li><p>Authenticates users with Kinde.</p>
</li>
<li><p>Stores to-do data in a Neon Postgres database.</p>
</li>
<li><p>Enforces billing limits and supports upgrades.</p>
</li>
<li><p>Exposes all these features as MCP tools inside Cursor.</p>
</li>
</ul>
<p>This article will walk you through each step, helping you understand design decisions that you can adapt for your own projects.</p>
<h2 id="heading-what-youll-learn">What You’ll Learn</h2>
<ul>
<li><p><a class="post-section-overview" href="#heading-why-go-beyond-basic-mcp-servers">Why Go Beyond Basic MCP Servers</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-what-youll-build">What You’ll Build</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-project-setup">Project Setup</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-database-setup-with-neon-postgresql">Database Setup with Neon PostgreSQL</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-1-connect-your-neon-database">1. Connect your Neon Database</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-2-create-your-db-file">2. Create your DB File</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-3-step-by-step-breakdown-of-setup-dbts">3. Step-by-Step Breakdown of setup-db.ts</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-4-full-setup-dbts-file">4. Full setup-db.ts File</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-authentication-with-kinde">Authentication with Kinde</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-1-create-a-kinde-application">1. Create a Kinde Application</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-2-configure-kinde-settings">2. Configure Kinde Settings</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-3-environment-variables">3. Environment Variables</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-4-create-the-kinde-auth-server">4. Create the Kinde Auth Server</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-5-complete-authentication-flow">5. Complete Authentication Flow</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-6-why-this-matters">6. Why This Matters</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-7-key-connections">7. Key Connections</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-8-full-kinde-auth-serverts-file">8. Full kinde-auth-server.ts File</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-mcp-server-implementation-with-billing-system-integration">MCP Server Implementation (with Billing System Integration)</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-1-create-your-file">1. Create Your File</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-2-project-setup-and-imports">2. Project Setup and Imports</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-3-database-connection-and-configuration">3. Database Connection and Configuration</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-4-authentication-system">4. Authentication System</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-5-core-helper-functions">5. Core Helper Functions</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-6-core-server-implementation">6. Core Server Implementation</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-7-register-tools">7. Register Tools</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-8-tool-handlers">8. Tool Handlers</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-9-full-serverts-file">9. Full server.ts File</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-10-data-flow-amp-integration">10. Data Flow &amp; Integration</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-11-error-handling-amp-security">11. Error Handling &amp; Security</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-12-testing-amp-deployment">12. Testing &amp; Deployment</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-testing-the-complete-system">Testing the Complete System</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-1-start-the-services">1. Start the Services</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-2-configure-cursor-mcp">2. Configure Cursor MCP</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-3-test-the-complete-flow">3. Test the Complete Flow</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-troubleshooting">Troubleshooting</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-1-mcp-server-not-detected">1. MCP Server Not Detected</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-2-database-connection-issues">2. Database Connection Issues</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-3-kinde-authentication-problems">3. Kinde Authentication Problems</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-4-token-errors">4. Token Errors</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-final-mcp-server-architecture">Final MCP Server Architecture</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-conclusion">Conclusion</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-next-steps">Next Steps</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-resources">Resources</a></p>
</li>
</ul>
</li>
</ul>
<h2 id="heading-why-go-beyond-basic-mcp-servers">Why Go Beyond Basic MCP Servers?</h2>
<p>If you read this <a target="_blank" href="https://www.freecodecamp.org/news/how-to-build-a-custom-mcp-server-with-typescript-a-handbook-for-developers">freeCodeCamp MCP handbook</a>, you learned how to set up a simple MCP server in TypeScript. That’s useful for learning the protocol, but it doesn’t reflect what you need in production.</p>
<p>A real application requires:</p>
<ul>
<li><p><strong>Authentication</strong> so each user has their own data and permissions.</p>
</li>
<li><p><strong>Persistence</strong> so data is stored in a reliable database.</p>
</li>
<li><p><strong>Billing</strong> so you can enforce limits and monetize usage.</p>
</li>
</ul>
<p>Without these, an MCP server is just a demo.</p>
<h2 id="heading-what-youll-build">What You’ll Build</h2>
<p>In this tutorial, you’ll build a to-do MCP server with TypeScript that includes the essentials of a production-ready backend:</p>
<ul>
<li><p><strong>Authentication</strong> with Kinde</p>
</li>
<li><p><strong>Database persistence</strong> with Neon Postgres</p>
</li>
<li><p><strong>Billing enforcement</strong> with a free tier and upgrade path</p>
</li>
<li><p><strong>MCP tool exposure</strong> so all of this works seamlessly</p>
</li>
</ul>
<p>By the end, you’ll have an MCP server that feels more like the backend of a SaaS app and a template you can extend for your own ideas.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before we start, you'll need:</p>
<p><strong>Accounts &amp; Services (all free to use):</strong></p>
<ul>
<li><p><a target="_blank" href="https://kinde.com">Kinde Account</a> <strong>→</strong> for authentication and billing</p>
</li>
<li><p><a target="_blank" href="https://neon.com">Neon Account</a> <strong>→</strong> for PostgreSQL database</p>
</li>
<li><p>Node.js (v18+) (<a target="_blank" href="https://nodejs.org/en/download">download</a>)</p>
</li>
<li><p>Cursor IDE <strong>→</strong> for MCP integration and tool testing (<a target="_blank" href="https://cursor.com/download">download</a>)</p>
</li>
</ul>
<p><strong>Development Tools:</strong></p>
<ul>
<li><p>Terminal/Command line access</p>
</li>
<li><p>Git (optional, for version control)</p>
</li>
</ul>
<h2 id="heading-project-setup">Project Setup</h2>
<p>First, create a new folder:</p>
<pre><code class="lang-powershell">mkdir todo<span class="hljs-literal">-mcp</span><span class="hljs-literal">-server</span>
<span class="hljs-built_in">cd</span> todo<span class="hljs-literal">-mcp</span><span class="hljs-literal">-server</span>
</code></pre>
<p>Then initialize a Node.js project:</p>
<pre><code class="lang-powershell">npm init <span class="hljs-literal">-y</span>
</code></pre>
<p>Next, install the dependencies your server will need:</p>
<pre><code class="lang-powershell">npm install @modelcontextprotocol/sdk @neondatabase/serverless @kinde<span class="hljs-literal">-oss</span>/kinde<span class="hljs-literal">-typescript</span><span class="hljs-literal">-sdk</span> express jsonwebtoken jwks<span class="hljs-literal">-client</span> express<span class="hljs-literal">-session</span>
</code></pre>
<p>The <code>@modelcontextprotocol/sdk</code> package gives us everything we need to build and expose MCP servers and tools. We’re using <code>@neondatabase/serverless</code> to connect to a Neon Postgres database, and <code>@kinde-oss/kinde-typescript-sdk</code> handles authentication and billing through Kinde.</p>
<p>We’ll also install <code>express</code>, which makes it easy to define routes and handle middleware. To verify user tokens from Kinde, we’ll use <code>jsonwebtoken</code> together with <code>jwks-client</code>. And finally, <code>express-session</code> will take care of managing session state so users can stay logged in across requests.</p>
<p>Next, set up TypeScript and a few type definitions for development:</p>
<pre><code class="lang-powershell">npm install <span class="hljs-literal">-D</span> typescript @types/node @types/express @types/express<span class="hljs-literal">-session</span> tsx
</code></pre>
<p>The <code>typescript</code> package enables TypeScript in your project so you can write strongly typed code. The <code>@types/*</code> packages provide type definitions for Node.js, Express, and the session middleware, giving you better autocomplete and error checking in your editor.</p>
<p>Finally, <code>tsx</code> makes it super easy to run TypeScript files directly without the need to pre-compile them before running your app.</p>
<p>Then create a <code>.env</code> file in your project root and paste these variables:</p>
<pre><code class="lang-json"># Database
DATABASE_URL=postgresql:<span class="hljs-comment">//user:pass@host:port/db</span>

# Kinde Authentication
KINDE_ISSUER_URL=https:<span class="hljs-comment">//your-domain.kinde.com</span>
KINDE_CLIENT_ID=your_client_id
KINDE_CLIENT_SECRET=your_client_secret

# Security
JWT_SECRET=your_secret_key

# Environment
NODE_ENV=development
</code></pre>
<p>This stores all the credentials that you’ll be using for this project.</p>
<p>Next, create a <code>tsconfig.json</code> in the project root to tell the TypeScript compiler how to handle your code:</p>
<pre><code class="lang-json">{
  <span class="hljs-attr">"compilerOptions"</span>: {
    <span class="hljs-attr">"target"</span>: <span class="hljs-string">"ES2022"</span>,
    <span class="hljs-attr">"module"</span>: <span class="hljs-string">"ESNext"</span>,
    <span class="hljs-attr">"moduleResolution"</span>: <span class="hljs-string">"node"</span>,
    <span class="hljs-attr">"strict"</span>: <span class="hljs-literal">true</span>,
    <span class="hljs-attr">"esModuleInterop"</span>: <span class="hljs-literal">true</span>,
    <span class="hljs-attr">"skipLibCheck"</span>: <span class="hljs-literal">true</span>,
    <span class="hljs-attr">"forceConsistentCasingInFileNames"</span>: <span class="hljs-literal">true</span>,
    <span class="hljs-attr">"outDir"</span>: <span class="hljs-string">"./dist"</span>,
    <span class="hljs-attr">"rootDir"</span>: <span class="hljs-string">"./src"</span>
  },
  <span class="hljs-attr">"include"</span>: [<span class="hljs-string">"src/**/*"</span>],
  <span class="hljs-attr">"exclude"</span>: [<span class="hljs-string">"node_modules"</span>, <span class="hljs-string">"dist"</span>]
}
</code></pre>
<p>Finally, update your <code>package.json</code> scripts</p>
<pre><code class="lang-json">{
  <span class="hljs-attr">"scripts"</span>: {
    <span class="hljs-attr">"build"</span>: <span class="hljs-string">"tsc"</span>,
    <span class="hljs-attr">"dev"</span>: <span class="hljs-string">"tsx src/server.ts"</span>,
    <span class="hljs-attr">"start"</span>: <span class="hljs-string">"node dist/server.js"</span>,
    <span class="hljs-attr">"auth-server"</span>: <span class="hljs-string">"tsx src/kinde-auth-server.ts"</span>,
    <span class="hljs-attr">"setup-db"</span>: <span class="hljs-string">"tsx src/setup-db.ts"</span>
  }
}
</code></pre>
<h2 id="heading-database-setup-with-neon-postgresql">Database Setup with Neon PostgreSQL</h2>
<p>To power your to-do MCP server, you’ll use Neon, a serverless PostgreSQL platform. This gives us a fully managed, scalable database without worrying about infrastructure.</p>
<h3 id="heading-1-connect-your-neon-database">1. Connect your Neon Database</h3>
<ul>
<li><p>Sign up or log in to your <a target="_blank" href="https://console.neon.tech">Neon account console</a>.</p>
</li>
<li><p>Create a new project.</p>
</li>
<li><p>Copy the connection string, you’ll need it in your <code>.env</code> file.</p>
</li>
</ul>
<h3 id="heading-2-create-your-db-file">2. Create your DB File</h3>
<p>Inside your project, create a new file in your <code>src/</code> folder and name it <code>setup-db.ts</code>. This file will create the tables, indexes, and schema your app relies on.</p>
<p>Database Architecture Overview:</p>
<pre><code class="lang-markdown">┌─────────────────┐    ┌─────────────────┐    ┌─────────────────┐
│   setup-db.ts   │───▶│  Neon Database  │───▶│   PostgreSQL    │
│   (Schema)      │    │   (Serverless)  │    │   (Tables)      │
└─────────────────┘    └─────────────────┘    └─────────────────┘
</code></pre>
<h3 id="heading-3-step-by-step-breakdown-of-setup-dbts">3. Step-by-Step Breakdown of <code>setup-db.ts</code></h3>
<p><strong>Step 1: Imports and Database Connection</strong></p>
<p>Start by importing the packages you’ll need and setting up your database connection:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">import</span> { neon } <span class="hljs-keyword">from</span> <span class="hljs-string">'@neondatabase/serverless'</span>;
<span class="hljs-keyword">import</span> dotenv <span class="hljs-keyword">from</span> <span class="hljs-string">'dotenv'</span>;

dotenv.config();

<span class="hljs-keyword">const</span> sql = neon(process.env.DATABASE_URL!);
</code></pre>
<p>The <code>dotenv</code> package loads your environment variables from a <code>.env</code> file so you don’t have to hardcode secrets in your code. The <code>neon</code> function connects your app to your Neon Postgres database, and the <code>sql</code> variable gives you a clean, type-safe way to run queries.</p>
<p>At this point, your app has everything it needs to talk to the database.</p>
<p><strong>Step 2: Main Setup Function</strong></p>
<p>Now let’s create a function to handle the database setup process:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">setupDatabase</span>(<span class="hljs-params"></span>) </span>{
  <span class="hljs-built_in">console</span>.log(<span class="hljs-string">'Setting up database schema...'</span>);
  <span class="hljs-keyword">try</span> {
    <span class="hljs-comment">// Database operations here</span>
  } <span class="hljs-keyword">catch</span> (error) {
    <span class="hljs-built_in">console</span>.error(<span class="hljs-string">'Error setting up database:'</span>, error);
    process.exit(<span class="hljs-number">1</span>);
  }
}
</code></pre>
<p>This function keeps all your schema creation logic in one place, making it easy to manage. It also catches and logs any errors instead of failing silently, so you’ll immediately know if something goes wrong. The console messages give you real-time feedback as the setup runs which is super helpful when you’re debugging or deploying.</p>
<p><strong>Step 3: To-Dos Table</strong></p>
<p>Next, create a table to store all user to-dos:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">await</span> sql<span class="hljs-string">`
  CREATE TABLE IF NOT EXISTS todos (
    id SERIAL PRIMARY KEY,
    user_id TEXT NOT NULL,
    title TEXT NOT NULL,
    description TEXT,
    completed BOOLEAN DEFAULT FALSE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
  )
`</span>;
</code></pre>
<p>This table holds every user’s tasks. The <code>user_id</code> column links each to-do to the user who created it, while the <code>completed</code> field tracks whether a task is done or still pending. The automatic <code>created_at</code> and <code>updated_at</code> timestamps make it easy to sort tasks or track their history over time.</p>
<p><strong>Step 4: Users Table</strong></p>
<p>Now, let’s define a table to manage user accounts and billing details:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">await</span> sql<span class="hljs-string">`
  CREATE TABLE IF NOT EXISTS users (
    id SERIAL PRIMARY KEY,
    user_id TEXT UNIQUE NOT NULL,
    name TEXT,
    email TEXT,
    subscription_status TEXT DEFAULT 'free' CHECK (subscription_status IN ('free', 'active', 'cancelled')),
    plan TEXT DEFAULT 'free',
    free_todos_used INTEGER DEFAULT 0,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
  )
`</span>;
</code></pre>
<p>This table stores each user’s basic information along with their subscription details. The <code>user_id</code> value comes directly from Kinde during authentication. The <code>subscription_status</code> and <code>free_todos_used</code> columns help you enforce billing tiers and limit how many free tasks a user can create before needing to upgrade.</p>
<p><strong>Step 5: Performance Indexes</strong></p>
<p>Next, let’s add a few indexes to make common database operations faster:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">await</span> sql<span class="hljs-string">`
  CREATE INDEX IF NOT EXISTS idx_todos_user_id ON todos(user_id)
`</span>;

<span class="hljs-keyword">await</span> sql<span class="hljs-string">`
  CREATE INDEX IF NOT EXISTS idx_todos_created_at ON todos(created_at)
`</span>;

<span class="hljs-keyword">await</span> sql<span class="hljs-string">`
  CREATE INDEX IF NOT EXISTS idx_users_user_id ON users(user_id)
`</span>;
</code></pre>
<p>These indexes help speed up lookups and sorting. The first one lets the database quickly find to-dos that belong to a specific user. The second makes it faster to sort tasks by their creation date. And the last one allows fast lookups of users based on their Kinde <code>user_id</code>.</p>
<p><strong>Step 6: Success Logging</strong></p>
<p>After everything runs, it’s helpful to log a clear summary of what was created:</p>
<pre><code class="lang-typescript"><span class="hljs-built_in">console</span>.log(<span class="hljs-string">'✅ Database schema created successfully!'</span>);
<span class="hljs-built_in">console</span>.log(<span class="hljs-string">'📋 Tables created:'</span>);
<span class="hljs-built_in">console</span>.log(<span class="hljs-string">'  - todos (id, user_id, title, description, completed, created_at, updated_at)'</span>);
<span class="hljs-built_in">console</span>.log(<span class="hljs-string">'  - users (id, user_id, subscription_status, plan, free_todos_used, created_at, updated_at)'</span>);
<span class="hljs-built_in">console</span>.log(<span class="hljs-string">'🔍 Indexes created for optimal performance'</span>);
</code></pre>
<p>These logs give you immediate feedback once the setup completes. They show exactly which tables and indexes were created, making it easy to confirm that your database schema is ready to go and everything ran as expected.</p>
<p><strong>Step 7: Error Handling</strong></p>
<pre><code class="lang-typescript"><span class="hljs-keyword">try</span> {
} <span class="hljs-keyword">catch</span> (error) {
  <span class="hljs-built_in">console</span>.error(<span class="hljs-string">'❌ Error setting up database:'</span>, error);
  process.exit(<span class="hljs-number">1</span>);
}
</code></pre>
<p>This handles any database setup errors gracefully.</p>
<p><strong>Step 8:</strong> Update your <code>.env</code> file in your project root with your Neon database connection string:</p>
<pre><code class="lang-json">DATABASE_URL=postgresql:<span class="hljs-comment">//username:password@host/database?sslmode=require</span>
</code></pre>
<p><strong>Step 9: Function Execution</strong></p>
<p>At the bottom of <code>setup-db.ts</code>, run the function:</p>
<pre><code class="lang-typescript">setupDatabase();
</code></pre>
<p>This immediately executes the database setup when the script runs.</p>
<p>Now, run this command in your CLI:</p>
<pre><code class="lang-powershell">npm run setup<span class="hljs-literal">-db</span>
</code></pre>
<p>Expected output:</p>
<pre><code class="lang-powershell">🚀 Setting up database schema...
✅ Database schema created successfully!
📋 Tables created:
  - todos (id, user_id, title, description, completed, created_at, updated_at)
  - users (id, user_id, subscription_status, plan, free_todos_used, created_at, updated_at)
🔍 Indexes created <span class="hljs-keyword">for</span> optimal performance
</code></pre>
<h3 id="heading-4-full-setup-dbts-file">4. Full <code>setup-db.ts</code> File</h3>
<p>You can view the complete implementation of the <code>setup-db.ts</code> file in the <a target="_blank" href="https://github.com/sholajegede/todo_mcp_server/blob/main/src/setup-db.ts">GitHub repo</a> and copy it directly into your project.</p>
<h2 id="heading-authentication-with-kinde">Authentication with Kinde</h2>
<p>To secure your MCP server, you’ll use <a target="_blank" href="https://kinde.com">Kinde</a>, an authentication provider that makes it easy to handle logins, user sessions, and tokens.</p>
<p>You’ll also connect Kinde to your Neon database so new users are automatically created when they log in.</p>
<h3 id="heading-1-create-a-kinde-application">1. Create a Kinde Application</h3>
<ul>
<li><p>Go to the <a target="_blank" href="https://app.kinde.com/admin">Kinde Dashboard</a>.</p>
</li>
<li><p>Create a new application.</p>
</li>
<li><p>Note down these values (you’ll use them shortly):</p>
<ul>
<li><p><strong>Domain</strong>: <a target="_blank" href="https://your-domain.kinde.com"><code>https://your-domain.kinde.com</code></a></p>
</li>
<li><p><strong>Client ID</strong>: <code>your-client-id</code></p>
</li>
<li><p><strong>Client Secret</strong>: <code>your-client-secret</code></p>
</li>
</ul>
</li>
</ul>
<h3 id="heading-2-configure-kinde-settings">2. Configure Kinde Settings</h3>
<p>In your Kinde dashboard, save these URLs as your:</p>
<ul>
<li><p>Redirect URL → <a target="_blank" href="http://localhost:3000/callback"><code>http://localhost:3000/callback</code></a></p>
</li>
<li><p>Logout URL → <a target="_blank" href="http://localhost:3000"><code>http://localhost:3000</code></a></p>
</li>
</ul>
<h3 id="heading-3-environment-variables">3. Environment Variables</h3>
<p>Update the <code>.env</code> file in your project root with the credentials from your Kinde Dashboard:</p>
<pre><code class="lang-json">KINDE_ISSUER_URL=https:<span class="hljs-comment">//your-domain.kinde.com</span>
KINDE_CLIENT_ID=your_client_id
KINDE_CLIENT_SECRET=your_client_secret
</code></pre>
<h3 id="heading-4-create-the-kinde-auth-server">4. Create the Kinde Auth Server</h3>
<p>You’ll build an Express server (<code>src/kinde-auth-server.ts</code>) to handle authentication. This server will manage OAuth login and logout with Kinde, store user sessions, and automatically create or update users in your Neon database whenever they sign in.</p>
<p><strong>4.1: Dependencies and Setup</strong></p>
<p>Start by importing the packages you’ll need:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">import</span> express <span class="hljs-keyword">from</span> <span class="hljs-string">'express'</span>;
<span class="hljs-keyword">import</span> session <span class="hljs-keyword">from</span> <span class="hljs-string">'express-session'</span>;
<span class="hljs-keyword">import</span> { createKindeServerClient, GrantType, SessionManager } <span class="hljs-keyword">from</span> <span class="hljs-string">'@kinde-oss/kinde-typescript-sdk'</span>;
<span class="hljs-keyword">import</span> jwt <span class="hljs-keyword">from</span> <span class="hljs-string">'jsonwebtoken'</span>;
<span class="hljs-keyword">import</span> dotenv <span class="hljs-keyword">from</span> <span class="hljs-string">'dotenv'</span>;
<span class="hljs-keyword">import</span> { neon } <span class="hljs-keyword">from</span> <span class="hljs-string">'@neondatabase/serverless'</span>;
</code></pre>
<p>The <code>express</code> import powers the web server that will handle authentication routes. <code>express-session</code> manages user sessions so you can persist login state between requests. The <code>@kinde-oss/kinde-typescript-sdk</code> package is the official Kinde SDK, which handles OAuth flows and user authentication.</p>
<p>You’ll use <code>jsonwebtoken</code> to decode and verify user tokens, while <code>dotenv</code> loads environment variables from your <code>.env</code> file. Finally, <code>@neondatabase/serverless</code> connects your server to the Neon Postgres database where user data will be stored.</p>
<p><strong>4.2: Connect to Your Database</strong></p>
<pre><code class="lang-typescript"><span class="hljs-keyword">const</span> sql = neon(process.env.DATABASE_URL!);
</code></pre>
<p>This initializes a type-safe SQL client using your <code>DATABASE_URL</code>.</p>
<p><strong>4.3: Extend login Session</strong></p>
<pre><code class="lang-typescript"><span class="hljs-keyword">declare</span> <span class="hljs-keyword">module</span> 'express-session' {
  <span class="hljs-keyword">interface</span> SessionData {
    accessToken?: <span class="hljs-built_in">string</span>;
    idToken?: <span class="hljs-built_in">string</span>;
    userInfo?: <span class="hljs-built_in">any</span>;
    userName?: <span class="hljs-built_in">string</span>;
    userEmail?: <span class="hljs-built_in">string</span>;
  }
}
</code></pre>
<p>This extends <code>express-session</code> types so you can store Kinde tokens and user info directly in the session.</p>
<p><strong>4.4: Configure Sessions</strong></p>
<p>Now, let’s configure session management so users can stay logged in across requests:</p>
<pre><code class="lang-typescript">app.use(session({
  secret: process.env.JWT_SECRET || <span class="hljs-string">'your_jwt_secret_key'</span>,
  resave: <span class="hljs-literal">true</span>,
  saveUninitialized: <span class="hljs-literal">true</span>,
  cookie: { 
    secure: <span class="hljs-literal">false</span>,
    maxAge: <span class="hljs-number">7</span> * <span class="hljs-number">24</span> * <span class="hljs-number">60</span> * <span class="hljs-number">60</span> * <span class="hljs-number">1000</span>, <span class="hljs-comment">// 7 days</span>
    httpOnly: <span class="hljs-literal">true</span>,
    sameSite: <span class="hljs-string">'lax'</span>
  }
}));
</code></pre>
<p>The <code>secret</code> value is used to sign and verify session cookies, ensuring that sessions can’t be tampered with. The cookie settings keep users logged in for up to 7 days and make sure session tokens persist even after a browser refresh.</p>
<p>Setting <code>httpOnly</code> helps protect against cross-site scripting (XSS) attacks, while <code>sameSite: 'lax'</code> allows users to log in across different origins without breaking authentication.</p>
<p><strong>4.5: Create a Session Manager Factory</strong></p>
<p>Next, you’ll define a small helper function to manage session data for each request:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">const</span> createSessionManager = (req: <span class="hljs-built_in">any</span>): <span class="hljs-function"><span class="hljs-params">SessionManager</span> =&gt;</span> ({
  getSessionItem: <span class="hljs-keyword">async</span> (key: <span class="hljs-built_in">string</span>) =&gt; req.session?.[key],
  setSessionItem: <span class="hljs-keyword">async</span> (key: <span class="hljs-built_in">string</span>, value: <span class="hljs-built_in">any</span>) =&gt; {
    <span class="hljs-keyword">if</span> (!req.session) req.session = {};
    req.session[key] = value;
  },
  removeSessionItem: <span class="hljs-keyword">async</span> (key: <span class="hljs-built_in">string</span>) =&gt; {
    <span class="hljs-keyword">if</span> (req.session) <span class="hljs-keyword">delete</span> req.session[key];
  },
  destroySession: <span class="hljs-keyword">async</span> () =&gt; {
    req.session = {};
  }
});
</code></pre>
<p>This function creates a session manager that’s tied to each request. It provides a consistent way to store, retrieve, and clear session data which is exactly what the Kinde SDK needs to keep track of tokens and user info during authentication.</p>
<p><strong>4.6: Create the Kinde Client</strong></p>
<p>Next, set up the Kinde client that will handle the OAuth login and logout flow:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">const</span> kindeClient = createKindeServerClient(GrantType.AUTHORIZATION_CODE, {
  authDomain: process.env.KINDE_ISSUER_URL!,
  clientId: process.env.KINDE_CLIENT_ID!,
  clientSecret: process.env.KINDE_CLIENT_SECRET!,
  redirectURL: <span class="hljs-string">'&lt;http://localhost:3000/callback&gt;'</span>,
  logoutRedirectURL: <span class="hljs-string">'&lt;http://localhost:3000&gt;'</span>,
});
</code></pre>
<p>This connects your app to Kinde using the <strong>Authorization Code</strong> grant type which is the most secure option for server-side applications like this one.</p>
<p>The <code>redirectURL</code> and <code>logoutRedirectURL</code> define where users should be sent after logging in or out.</p>
<p><strong>4.7: Create the Home Page Route (</strong><code>GET /</code><strong>)</strong></p>
<p>Then you’ll define a basic route for your home page that checks whether a user is logged in:</p>
<pre><code class="lang-typescript">app.get(<span class="hljs-string">'/'</span>, <span class="hljs-function">(<span class="hljs-params">req, res</span>) =&gt;</span> {
  <span class="hljs-keyword">const</span> token = req.session?.accessToken;
  <span class="hljs-keyword">const</span> userInfo = req.session?.userInfo;

  <span class="hljs-keyword">if</span> (token) {
    <span class="hljs-comment">// Show logged-in state with tokens</span>
  } <span class="hljs-keyword">else</span> {
    <span class="hljs-comment">// Show login button</span>
  }
});
</code></pre>
<p>This route reads the session to determine if a user is authenticated. If a token exists, you can display their account details or dashboard. Otherwise, you’ll show a login button that directs them to Kinde’s sign-in page.</p>
<p><strong>4.8: Create the Login Route (</strong><code>GET /login</code><strong>)</strong></p>
<p>Now, add a route that starts the OAuth login process:</p>
<pre><code class="lang-typescript">app.get(<span class="hljs-string">'/login'</span>, <span class="hljs-keyword">async</span> (req, res) =&gt; {
  <span class="hljs-keyword">try</span> {
    <span class="hljs-keyword">const</span> sessionManager = createSessionManager(req);
    <span class="hljs-keyword">const</span> loginUrl = <span class="hljs-keyword">await</span> kindeClient.login(sessionManager);
    res.redirect(loginUrl.toString());
  } <span class="hljs-keyword">catch</span> (error) {
    <span class="hljs-built_in">console</span>.error(<span class="hljs-string">'Login error:'</span>, error);
    res.status(<span class="hljs-number">500</span>).send(<span class="hljs-string">'Login failed'</span>);
  }
});
</code></pre>
<p>When users visit this route, your app uses the Kinde client to generate a secure login URL and redirects them to Kinde’s hosted login page. Once they log in, Kinde will send them back to your callback route with the necessary tokens.</p>
<p><strong>4.9: OAuth Callback Route (</strong><code>GET /callback</code><strong>)</strong></p>
<p>This is where Kinde redirects users back after login. First, you’ll get the authorization <code>code</code> from Kinde for token exchange:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">const</span> fullUrl = <span class="hljs-string">`http://<span class="hljs-subst">${req.headers.host}</span><span class="hljs-subst">${req.url}</span>`</span>;
<span class="hljs-keyword">const</span> url = <span class="hljs-keyword">new</span> URL(fullUrl);
<span class="hljs-keyword">const</span> code = url.searchParams.get(<span class="hljs-string">'code'</span>);
</code></pre>
<p>Next, you’ll exchange this code for tokens that will be needed for API calls:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">const</span> tokenResponse = <span class="hljs-keyword">await</span> fetch(<span class="hljs-string">`<span class="hljs-subst">${process.env.KINDE_ISSUER_URL}</span>/oauth2/token`</span>, {
  method: <span class="hljs-string">'POST'</span>,
  headers: { <span class="hljs-string">'Content-Type'</span>: <span class="hljs-string">'application/x-www-form-urlencoded'</span> },
  body: <span class="hljs-keyword">new</span> URLSearchParams({
    grant_type: <span class="hljs-string">'authorization_code'</span>,
    client_id: process.env.KINDE_CLIENT_ID!,
    client_secret: process.env.KINDE_CLIENT_SECRET!,
    code: code,
    redirect_uri: <span class="hljs-string">'http://localhost:3000/callback'</span>,
  }),
});
</code></pre>
<p>Then, you’ll store these tokens in the session for future requests so users don't need to login every time:</p>
<pre><code class="lang-typescript">req.session.accessToken = tokenData.access_token;
req.session.idToken = tokenData.id_token;
req.session.userInfo = tokenData;
</code></pre>
<p>The next thing you’ll do is to extract the user’s information from JWT and then store this in your database</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">const</span> user = <span class="hljs-built_in">JSON</span>.parse(Buffer.from(idToken.split(<span class="hljs-string">'.'</span>)[<span class="hljs-number">1</span>], <span class="hljs-string">'base64'</span>).toString());
req.session.userName = user.given_name || user.name || <span class="hljs-string">'User'</span>;
req.session.userEmail = user.email || <span class="hljs-string">'user@example.com'</span>;
</code></pre>
<p>Finally, add this code that will automatically create or update the user in the database so your MCP server can track to-dos and billing:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">const</span> existingUser = <span class="hljs-keyword">await</span> sql<span class="hljs-string">`SELECT * FROM users WHERE user_id = <span class="hljs-subst">${userId}</span>`</span>;

<span class="hljs-keyword">if</span> (existingUser.length === <span class="hljs-number">0</span>) {
  <span class="hljs-keyword">await</span> sql<span class="hljs-string">`
    INSERT INTO users (user_id, name, email, subscription_status, plan, free_todos_used)
    VALUES (<span class="hljs-subst">${userId}</span>, <span class="hljs-subst">${userName}</span>, <span class="hljs-subst">${userEmail}</span>, 'free', 'free', 0)
  `</span>;
} <span class="hljs-keyword">else</span> {
  <span class="hljs-keyword">await</span> sql<span class="hljs-string">`
    UPDATE users 
    SET name = <span class="hljs-subst">${userName}</span>, email = <span class="hljs-subst">${userEmail}</span>
    WHERE user_id = <span class="hljs-subst">${userId}</span>
  `</span>;
}
</code></pre>
<p><strong>4.10: Create the Logout Route (</strong><code>GET /logout</code><strong>)</strong></p>
<pre><code class="lang-typescript">app.get(<span class="hljs-string">'/logout'</span>, <span class="hljs-keyword">async</span> (req, res) =&gt; {
  <span class="hljs-keyword">try</span> {
    req.session.destroy(<span class="hljs-function">(<span class="hljs-params">err</span>) =&gt;</span> {
      <span class="hljs-keyword">if</span> (err) {
        <span class="hljs-built_in">console</span>.log(<span class="hljs-string">'Session destroy error:'</span>, err);
      }
      res.redirect(<span class="hljs-string">'/'</span>);
    });
  } <span class="hljs-keyword">catch</span> (error) {
    <span class="hljs-built_in">console</span>.error(<span class="hljs-string">'Logout error:'</span>, error);
    res.status(<span class="hljs-number">500</span>).send(<span class="hljs-string">'Logout failed'</span>);
  }
});
</code></pre>
<p>This route simply destroys the user’s session, removing all stored tokens and user information. Once the session is cleared, it redirects the user back to the home page, effectively logging them out of the app.</p>
<h3 id="heading-5-complete-authentication-flow">5. Complete Authentication Flow</h3>
<pre><code class="lang-markdown"><span class="hljs-bullet">1.</span> User visits / → sees login button.
<span class="hljs-bullet">2.</span> Clicks login → goes to Kinde.
<span class="hljs-bullet">3.</span> Logs in → redirected back to /callback.
<span class="hljs-bullet">4.</span> Callback exchanges code for tokens.
<span class="hljs-bullet">5.</span> Tokens stored in session.
<span class="hljs-bullet">6.</span> User is created/updated in database.
<span class="hljs-bullet">7.</span> User can now access the MCP server.
</code></pre>
<h3 id="heading-6-why-this-matters">6. Why This Matters</h3>
<p>By placing Kinde in front of your MCP server, you get a secure and seamless authentication layer without the extra trouble of handling passwords or tokens manually. Your users can log in safely, and their sessions persist across page refreshes without the need to log in again each time they revisit your app.</p>
<p>Every new user who signs in is automatically added to your Neon database, making it easy to track accounts and usage. This setup also lays the groundwork for more advanced features later on, like enforcing billing limits or managing user-specific to-dos.</p>
<h3 id="heading-7-key-connections">7. Key Connections</h3>
<ul>
<li><p><strong>Session ↔ Database</strong>: Sync user data</p>
</li>
<li><p><strong>Kinde ↔ Session</strong>: Tokens flow from Kinde to session storage</p>
</li>
<li><p><strong>Session ↔ MCP</strong>: Tokens passed into the server for access control</p>
</li>
<li><p><strong>Database ↔ MCP</strong>: User billing + to-dos read from Neon</p>
</li>
</ul>
<h3 id="heading-8-full-kinde-auth-serverts-file">8. Full <code>kinde-auth-server.ts</code> File</h3>
<p>You can view the complete implementation of the <code>kinde-auth-server.ts</code> file in the <a target="_blank" href="https://github.com/sholajegede/todo_mcp_server/blob/main/src/kinde-auth-server.ts">GitHub repo</a> and copy it directly into your project.</p>
<h2 id="heading-mcp-server-implementation-with-billing-system-integration">MCP Server Implementation (with Billing System Integration)</h2>
<p>Now it’s time to create the main file for your MCP server. This file acts as the entry point, wiring up your database, authentication, tool handlers, and overall flow into a single server.</p>
<h3 id="heading-1-create-your-file">1. Create Your File</h3>
<p>Inside your project, create a new file:</p>
<pre><code class="lang-bash">src/server.ts
</code></pre>
<p>This file will contain the full implementation of your MCP server.</p>
<h3 id="heading-2-project-setup-and-imports">2. Project Setup and Imports</h3>
<p>At the top of the file, import the dependencies you’ll need:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">import</span> { Server } <span class="hljs-keyword">from</span> <span class="hljs-string">'@modelcontextprotocol/sdk/server/index.js'</span>;
<span class="hljs-keyword">import</span> { StdioServerTransport } <span class="hljs-keyword">from</span> <span class="hljs-string">'@modelcontextprotocol/sdk/server/stdio.js'</span>;
<span class="hljs-keyword">import</span> { CallToolRequestSchema, ListToolsRequestSchema } <span class="hljs-keyword">from</span> <span class="hljs-string">'@modelcontextprotocol/sdk/types.js'</span>;
<span class="hljs-keyword">import</span> { neon } <span class="hljs-keyword">from</span> <span class="hljs-string">'@neondatabase/serverless'</span>;
<span class="hljs-keyword">import</span> jwt <span class="hljs-keyword">from</span> <span class="hljs-string">'jsonwebtoken'</span>;
<span class="hljs-keyword">import</span> fs <span class="hljs-keyword">from</span> <span class="hljs-string">'fs'</span>;
<span class="hljs-keyword">import</span> path <span class="hljs-keyword">from</span> <span class="hljs-string">'path'</span>;
</code></pre>
<p>Each import in your MCP server has a specific purpose. The <code>Server</code> class is the foundation that powers your entire implementation. It listens for requests, manages responses, and keeps track of all registered tools. The <code>StdioServerTransport</code> handles communication between your MCP server and other tools through standard input and output, which is exactly how Cursor connects behind the scenes.</p>
<p>The <code>CallToolRequestSchema</code> and <code>ListToolsRequestSchema</code> act as validators, ensuring every incoming request follows the correct structure before it’s processed. This reduces errors and keeps communication between your tools and the MCP client consistent.</p>
<p><code>neon</code> connects your server to the Neon PostgreSQL database, providing a clean way to manage persistent data like users and to-dos. The <code>jsonwebtoken</code> library decodes and verifies tokens from Kinde, letting you identify and authenticate users securely.</p>
<p><code>fs</code> is used to read and write authentication tokens locally, which means users don’t need to log in every time. Finally, <code>path</code> helps manage file paths cleanly across different systems, keeping everything organized and portable.</p>
<p>Together, these imports form the backbone of your server’s logic, handling authentication, database access, and reliable communication with Cursor.</p>
<h3 id="heading-3-database-connection-and-configuration">3. Database Connection and Configuration</h3>
<p>Next, configure your database connection and token storage:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">const</span> sql = neon(process.env.DATABASE_URL!);
<span class="hljs-keyword">const</span> TOKEN_FILE = <span class="hljs-string">'.auth-token'</span>;
</code></pre>
<p>The <code>sql</code> constant creates a live connection to your Neon PostgreSQL database using the <code>DATABASE_URL</code> environment variable. Think of it as the bridge that lets your MCP server talk to your database with every query, insert, and update running through this connection.</p>
<p>It’s what allows your server to persist user data, to-dos, and billing information reliably without having to manage complex configurations manually.</p>
<p>The <code>TOKEN_FILE</code> constant, on the other hand, acts as a lightweight local storage system for authentication tokens. Whenever a user logs in, their token is saved here so they don’t have to reauthenticate every time they restart the server.</p>
<p>It’s a simple but effective way to maintain session continuity, especially during local development or testing.</p>
<h3 id="heading-4-authentication-system">4. Authentication System</h3>
<p>To manage tokens, you’ll add three helper functions:</p>
<p><strong>4.1. Get Stored Token</strong></p>
<pre><code class="lang-typescript"><span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">getStoredToken</span>(<span class="hljs-params"></span>): <span class="hljs-title">string</span> | <span class="hljs-title">null</span> </span>{
  <span class="hljs-keyword">try</span> {
    <span class="hljs-keyword">if</span> (fs.existsSync(TOKEN_FILE)) {
      <span class="hljs-keyword">return</span> fs.readFileSync(TOKEN_FILE, <span class="hljs-string">'utf8'</span>).trim();
    }
  } <span class="hljs-keyword">catch</span> (error) {
    <span class="hljs-built_in">console</span>.error(<span class="hljs-string">'Error reading token file:'</span>, error);
  }
  <span class="hljs-keyword">return</span> <span class="hljs-literal">null</span>;
}
</code></pre>
<p>Retrieves a saved JWT token from the local file system.</p>
<p><strong>4.2. Store Token</strong></p>
<pre><code class="lang-typescript"><span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">storeToken</span>(<span class="hljs-params">token: <span class="hljs-built_in">string</span></span>): <span class="hljs-title">void</span> </span>{
  <span class="hljs-keyword">try</span> {
    fs.writeFileSync(TOKEN_FILE, token);
  } <span class="hljs-keyword">catch</span> (error) {
    <span class="hljs-built_in">console</span>.error(<span class="hljs-string">'Error storing token:'</span>, error);
  }
}
</code></pre>
<p>Stores a new JWT token locally so authentication persists across server restarts.</p>
<p><strong>4.3. Decode JWT</strong></p>
<pre><code class="lang-typescript"><span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">decodeJWT</span>(<span class="hljs-params">token: <span class="hljs-built_in">string</span></span>): <span class="hljs-title">any</span> </span>{
  <span class="hljs-keyword">try</span> {
    <span class="hljs-keyword">return</span> jwt.decode(token);
  } <span class="hljs-keyword">catch</span> (error) {
    <span class="hljs-built_in">console</span>.error(<span class="hljs-string">'Error decoding JWT:'</span>, error);
    <span class="hljs-keyword">return</span> <span class="hljs-literal">null</span>;
  }
}
</code></pre>
<p>Decodes JWTs to extract user info such as ID, email, and subscription status.</p>
<h3 id="heading-5-core-helper-functions">5. Core Helper Functions</h3>
<p><strong>5.1. Check Billing Status</strong></p>
<pre><code class="lang-typescript"><span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">getKindeBillingStatus</span>(<span class="hljs-params">userId: <span class="hljs-built_in">string</span>, accessToken: <span class="hljs-built_in">string</span></span>): <span class="hljs-title">Promise</span>&lt;</span>{ plan: <span class="hljs-built_in">string</span>; features: <span class="hljs-built_in">any</span>; canCreate: <span class="hljs-built_in">boolean</span>; reason?: <span class="hljs-built_in">string</span> }&gt; {
  <span class="hljs-keyword">try</span> {
    <span class="hljs-keyword">const</span> decoded = jwt.decode(accessToken) <span class="hljs-keyword">as</span> <span class="hljs-built_in">any</span>;
    <span class="hljs-built_in">console</span>.log(<span class="hljs-string">'🔍 JWT Token data for user:'</span>, userId, <span class="hljs-string">'Decoded:'</span>, decoded);

    <span class="hljs-keyword">const</span> subscription = <span class="hljs-keyword">await</span> sql<span class="hljs-string">`
      SELECT * FROM users 
      WHERE user_id = <span class="hljs-subst">${userId}</span>
    `</span>;

    <span class="hljs-keyword">if</span> (subscription.length === <span class="hljs-number">0</span>) {
      <span class="hljs-keyword">await</span> sql<span class="hljs-string">`
        INSERT INTO users (user_id, name, email, subscription_status, plan, free_todos_used)
        VALUES (<span class="hljs-subst">${userId}</span>, <span class="hljs-subst">${decoded.given_name || decoded.name || <span class="hljs-string">'User'</span>}</span>, <span class="hljs-subst">${decoded.email || <span class="hljs-string">'user@example.com'</span>}</span>, 'free', 'free', 0)
      `</span>;
      <span class="hljs-built_in">console</span>.log(<span class="hljs-string">'👤 New user created:'</span>, decoded.given_name || decoded.name, decoded.email);
    }

    <span class="hljs-keyword">const</span> freeTodosUsed = subscription.length &gt; <span class="hljs-number">0</span> ? subscription[<span class="hljs-number">0</span>].free_todos_used : <span class="hljs-number">0</span>;

    <span class="hljs-keyword">if</span> (freeTodosUsed &lt; <span class="hljs-number">1</span>) {
      <span class="hljs-keyword">return</span> {
        plan: <span class="hljs-string">'free'</span>,
        features: { maxTodos: <span class="hljs-number">1</span>, used: freeTodosUsed },
        canCreate: <span class="hljs-literal">true</span>,
        reason: <span class="hljs-string">`Free tier - <span class="hljs-subst">${<span class="hljs-number">1</span> - freeTodosUsed}</span> todo remaining`</span>
      };
    }

    <span class="hljs-keyword">return</span> {
      plan: <span class="hljs-string">'free'</span>,
      features: { maxTodos: <span class="hljs-number">1</span>, used: freeTodosUsed },
      canCreate: <span class="hljs-literal">false</span>,
      reason: <span class="hljs-string">'You have used your free todo. Please upgrade your plan at &lt;https://learnflowai.kinde.com/portal&gt; to create more todos.'</span>
    };
  } <span class="hljs-keyword">catch</span> (error) {
    <span class="hljs-built_in">console</span>.error(<span class="hljs-string">'Error checking Kinde billing:'</span>, error);
    <span class="hljs-keyword">return</span> {
      plan: <span class="hljs-string">'free'</span>,
      features: { maxTodos: <span class="hljs-number">1</span> },
      canCreate: <span class="hljs-literal">false</span>,
      reason: <span class="hljs-string">'Error checking billing status'</span>
    };
  }
}
</code></pre>
<p>This checks a user's billing status and enforces a 1-todo free tier. It also auto-creates a user in the database if it doesn’t exist.</p>
<p><strong>5.2. Check To-Do Permission</strong></p>
<pre><code class="lang-typescript"><span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">canCreateTodo</span>(<span class="hljs-params">userId: <span class="hljs-built_in">string</span>, accessToken: <span class="hljs-built_in">string</span></span>): <span class="hljs-title">Promise</span>&lt;<span class="hljs-title">boolean</span>&gt; </span>{
  <span class="hljs-keyword">const</span> billingStatus = <span class="hljs-keyword">await</span> getKindeBillingStatus(userId, accessToken);
  <span class="hljs-keyword">return</span> billingStatus.canCreate;
}
</code></pre>
<p>This is a simple wrapper that returns a boolean for permission checks.</p>
<h3 id="heading-6-core-server-implementation">6. Core Server Implementation</h3>
<p>Initialize your MCP server:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">const</span> server = <span class="hljs-keyword">new</span> Server(
  {
    name: <span class="hljs-string">'todo-mcp-server'</span>,
    version: <span class="hljs-string">'1.0.0'</span>,
  },
  {
    capabilities: {
      tools: {},
    },
  }
);
</code></pre>
<p>This declares a new MCP server with the capability to register tools.</p>
<h3 id="heading-7-register-tools">7. Register Tools</h3>
<p>Every tool that your Cursor IDE can use needs to be listed so it knows what’s available. In this part of the server setup, you’re registering all the tools that your MCP server will expose. Think of it like giving Cursor a menu of what your backend can do.</p>
<p>Here’s how that looks in code:</p>
<pre><code class="lang-typescript">server.setRequestHandler(ListToolsRequestSchema, <span class="hljs-keyword">async</span> () =&gt; {
  <span class="hljs-keyword">return</span> {
    tools: [
      {
        name: <span class="hljs-string">'login'</span>,
        description: <span class="hljs-string">'Get authentication URL for Kinde login'</span>,
        inputSchema: {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'object'</span>,
          properties: {},
        },
      },
      <span class="hljs-comment">// ... more tools</span>
    ],
  };
});
</code></pre>
<p>Tools you’ll create include:</p>
<ul>
<li><p>Authentication → <code>login</code>, <code>save_token</code>, <code>logout</code></p>
</li>
<li><p>To-Do Management → <code>list_todos</code>, <code>create_todo</code>, <code>update_todo</code>, <code>delete_todo</code></p>
</li>
<li><p>Billing → <code>refresh_billing_status</code></p>
</li>
</ul>
<h3 id="heading-8-tool-handlers">8. Tool Handlers</h3>
<p>Each tool in your MCP server has its own handler. The handler checks if the user is authenticated, talks to the database to perform the request, and returns a clean, structured response that Cursor can display.</p>
<p>This keeps the server organized, secure, and easy to extend later.</p>
<p><strong>8.1. Login Tool</strong></p>
<p>The <code>login</code> tool is responsible for starting the authentication flow with Kinde. When users call it, the server returns a short message explaining how to sign in and store their token.</p>
<p>Once logged in, it lists a few commands that they user can try.</p>
<p>Here’s the handler:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">case</span> <span class="hljs-string">'login'</span>: {
  <span class="hljs-keyword">return</span> {
    content: [
      {
        <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
        text: <span class="hljs-string">`🔐 **Authentication Required**

To use this MCP server, you need to authenticate with Kinde:

1. **Open your browser** and go to: &lt;http://localhost:3000&gt;
2. **Click "Login with Kinde"** to authenticate
3. **Copy your ID Token** from the page
4. **Use the save_token tool** to store it

**Note:** Make sure the Kinde auth server is running:
\\`</span>\\<span class="hljs-string">`\\`</span>bash
npm run auth-server
\\<span class="hljs-string">`\\`</span>\\<span class="hljs-string">`

After authentication, you can use commands like:
- \\`</span>list todos\\<span class="hljs-string">` - List your todos
- \\`</span>create todo\\<span class="hljs-string">` - Create a new todo
- \\`</span>refresh billing status\\<span class="hljs-string">` - Check your plan status`</span>,
      },
    ],
  };
}
</code></pre>
<p><strong>8.2. Save Token Tool</strong></p>
<p>The <code>save_token</code> tool handles storing the user’s authentication token locally so they don’t need to re-authenticate each time they use the MCP server.</p>
<p>Here’s the handler:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">case</span> <span class="hljs-string">'save_token'</span>: {
  <span class="hljs-keyword">const</span> { token } = args <span class="hljs-keyword">as</span> { token: <span class="hljs-built_in">string</span> };
  storeToken(token);
  <span class="hljs-keyword">return</span> {
    content: [
      {
        <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
        text: <span class="hljs-string">'✅ Token saved successfully! You can now use commands like "list todos" and "create todo" without providing the token each time.'</span>,
      },
    ],
  };
}
</code></pre>
<p>When a user runs this command and passes in their token, the server saves it to a local file using the <code>storeToken()</code> function. From then on, every other command (like <code>list todos</code>, <code>create todo</code>, or <code>refresh billing status</code>) can automatically authenticate using that stored token.</p>
<p>This small step makes the development flow smoother and keeps authentication persistent across sessions.</p>
<p><strong>8.3. List To-Dos Tool</strong></p>
<p>The <code>list_todos</code> tool retrieves all to-dos that belong to the currently authenticated user. It first checks for a stored authentication token and decodes it to identify the user. If the token is missing or invalid, it asks the user to log in again.</p>
<p>Here’s the handler:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">case</span> <span class="hljs-string">'list_todos'</span>: {
  <span class="hljs-keyword">const</span> token = getStoredToken();
  <span class="hljs-keyword">if</span> (!token) {
    <span class="hljs-keyword">return</span> {
      content: [
        {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
          text: <span class="hljs-string">'❌ No authentication token found. Please:\\n1. Type "login" to get the authentication URL\\n2. Complete login at &lt;http://localhost:3000&gt;\\n3. Copy your token and use "save_token" to store it\\n4. Then try "list todos" again'</span>,
        },
      ],
    ];
  }

  <span class="hljs-keyword">const</span> decoded = decodeJWT(token);
  <span class="hljs-keyword">if</span> (!decoded || !decoded.sub) {
    <span class="hljs-keyword">return</span> {
      content: [
        {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
          text: <span class="hljs-string">'❌ Invalid token. Please login again using the "login" command.'</span>,
        },
      ],
    ];
  }

  <span class="hljs-keyword">const</span> todos = <span class="hljs-keyword">await</span> sql<span class="hljs-string">`
    SELECT * FROM todos 
    WHERE user_id = <span class="hljs-subst">${decoded.sub}</span> 
    ORDER BY created_at DESC
  `</span>;

  <span class="hljs-keyword">if</span> (todos.length === <span class="hljs-number">0</span>) {
    <span class="hljs-keyword">return</span> {
      content: [
        {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
          text: <span class="hljs-string">'📝 No todos found. Create your first todo using "create todo"!'</span>,
        },
      ],
    ];
  }

  <span class="hljs-keyword">const</span> todosList = todos.map(<span class="hljs-function">(<span class="hljs-params">todo: <span class="hljs-built_in">any</span></span>) =&gt;</span> 
    <span class="hljs-string">`**<span class="hljs-subst">${todo.id}</span>.** <span class="hljs-subst">${todo.title}</span><span class="hljs-subst">${todo.description ? <span class="hljs-string">` - <span class="hljs-subst">${todo.description}</span>`</span> : <span class="hljs-string">''</span>}</span> <span class="hljs-subst">${todo.completed ? <span class="hljs-string">'✅'</span> : <span class="hljs-string">'⏳'</span>}</span>`</span>
  ).join(<span class="hljs-string">'\\n'</span>);

  <span class="hljs-keyword">return</span> {
    content: [
      {
        <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
        text: <span class="hljs-string">`📝 **Your Todos (<span class="hljs-subst">${todos.length}</span>):**\\n\\n<span class="hljs-subst">${todosList}</span>`</span>,
      },
    ],
  };
}
</code></pre>
<p>Results from this tool are formatted for easy reading in the MCP client, showing each task’s title, description, and completion status. If there are no to-dos yet, it simply prompts the user to create one.</p>
<p><strong>8.4. Create To-Do Tool</strong></p>
<p>The <code>create_todo</code> tool lets authenticated users add new to-dos to their list. It starts by verifying that a valid token exists, ensuring only logged-in users can create tasks. If the token is present, it checks billing limits otherwise, it instructs the user to log in again.</p>
<p>Here’s the handler:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">case</span> <span class="hljs-string">'create_todo'</span>: {
  <span class="hljs-keyword">const</span> token = getStoredToken();
  <span class="hljs-keyword">if</span> (!token) {
    <span class="hljs-keyword">return</span> {
      content: [
        {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
          text: <span class="hljs-string">'❌ No authentication token found. Please login first.'</span>,
        },
      ],
    ];
  }

  <span class="hljs-keyword">const</span> decoded = decodeJWT(token);
  <span class="hljs-keyword">if</span> (!decoded || !decoded.sub) {
    <span class="hljs-keyword">return</span> {
      content: [
        {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
          text: <span class="hljs-string">'❌ Invalid token. Please login again.'</span>,
        },
      ],
    ];
  }

  <span class="hljs-keyword">const</span> { title, description, completed } = args <span class="hljs-keyword">as</span> { 
    title: <span class="hljs-built_in">string</span>; 
    description?: <span class="hljs-built_in">string</span>; 
    completed?: <span class="hljs-built_in">boolean</span>; 
  };

  <span class="hljs-comment">// Check billing status before creating todo</span>
  <span class="hljs-keyword">const</span> canCreate = <span class="hljs-keyword">await</span> canCreateTodo(decoded.sub, token);
  <span class="hljs-keyword">if</span> (!canCreate) {
    <span class="hljs-keyword">const</span> billingStatus = <span class="hljs-keyword">await</span> getKindeBillingStatus(decoded.sub, token);
    <span class="hljs-keyword">return</span> {
      content: [
        {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
          text: <span class="hljs-string">`🚫 **Cannot create todo**

<span class="hljs-subst">${billingStatus.reason}</span>

**Upgrade your plan:** &lt;https://learnflowai.kinde.com/portal`</span>&gt;,
        },
      ],
    ];
  }

  <span class="hljs-keyword">const</span> result = <span class="hljs-keyword">await</span> sql<span class="hljs-string">`
    INSERT INTO todos (user_id, title, description, completed)
    VALUES (<span class="hljs-subst">${decoded.sub}</span>, <span class="hljs-subst">${title}</span>, <span class="hljs-subst">${description || <span class="hljs-literal">null</span>}</span>, <span class="hljs-subst">${completed || <span class="hljs-literal">false</span>}</span>)
    RETURNING *
  `</span>;

  <span class="hljs-comment">// Update free todos used count</span>
  <span class="hljs-keyword">await</span> sql<span class="hljs-string">`
    UPDATE users 
    SET free_todos_used = free_todos_used + 1 
    WHERE user_id = <span class="hljs-subst">${decoded.sub}</span>
  `</span>;

  <span class="hljs-keyword">return</span> {
    content: [
      {
        <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
        text: <span class="hljs-built_in">JSON</span>.stringify({
          success: <span class="hljs-literal">true</span>,
          todoId: result[<span class="hljs-number">0</span>].id,
          message: <span class="hljs-string">'Todo created successfully'</span>,
          title: result[<span class="hljs-number">0</span>].title,
          description: result[<span class="hljs-number">0</span>].description,
          completed: result[<span class="hljs-number">0</span>].completed
        }, <span class="hljs-literal">null</span>, <span class="hljs-number">2</span>),
      },
    ],
  };
}
</code></pre>
<p>If the user has hit their limit after calling this tool, it returns a clear message explaining why and provides a link to upgrade their plan.</p>
<p><strong>8.5. Update To-Do Tool</strong></p>
<p>The <code>update_todo</code> tool allows an authenticated user to modify an existing to-do’s title, description, or completion status. It first checks for a valid token and decodes it to identify the user. If authentication fails, the tool instructs the user to log in again.</p>
<p>Here’s the handler:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">case</span> <span class="hljs-string">'update_todo'</span>: {
  <span class="hljs-keyword">const</span> token = getStoredToken();
  <span class="hljs-keyword">if</span> (!token) {
    <span class="hljs-keyword">return</span> {
      content: [
        {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
          text: <span class="hljs-string">'❌ No authentication token found. Please login first.'</span>,
        },
      ],
    ];
  }

  <span class="hljs-keyword">const</span> decoded = decodeJWT(token);
  <span class="hljs-keyword">if</span> (!decoded || !decoded.sub) {
    <span class="hljs-keyword">return</span> {
      content: [
        {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
          text: <span class="hljs-string">'❌ Invalid token. Please login again.'</span>,
        },
      ],
    ];
  }

  <span class="hljs-keyword">const</span> { todoId, title, description, completed } = args <span class="hljs-keyword">as</span> { 
    todoId: <span class="hljs-built_in">number</span>; 
    title?: <span class="hljs-built_in">string</span>; 
    description?: <span class="hljs-built_in">string</span>; 
    completed?: <span class="hljs-built_in">boolean</span>; 
  };

  <span class="hljs-keyword">const</span> result = <span class="hljs-keyword">await</span> sql<span class="hljs-string">`
    UPDATE todos 
    SET 
      title = COALESCE(<span class="hljs-subst">${title || <span class="hljs-literal">null</span>}</span>, title),
      description = COALESCE(<span class="hljs-subst">${description || <span class="hljs-literal">null</span>}</span>, description),
      completed = COALESCE(<span class="hljs-subst">${completed !== <span class="hljs-literal">undefined</span> ? completed : <span class="hljs-literal">null</span>}</span>, completed),
      updated_at = CURRENT_TIMESTAMP
    WHERE id = <span class="hljs-subst">${todoId}</span> AND user_id = <span class="hljs-subst">${decoded.sub}</span>
    RETURNING *
  `</span>;

  <span class="hljs-keyword">if</span> (result.length === <span class="hljs-number">0</span>) {
    <span class="hljs-keyword">return</span> {
      content: [
        {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
          text: <span class="hljs-string">'❌ Todo not found or you do not have permission to update it.'</span>,
        },
      ],
    ];
  }

  <span class="hljs-keyword">return</span> {
    content: [
      {
        <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
        text: <span class="hljs-built_in">JSON</span>.stringify({
          success: <span class="hljs-literal">true</span>,
          message: <span class="hljs-string">'Todo updated successfully'</span>,
          todo: result[<span class="hljs-number">0</span>]
        }, <span class="hljs-literal">null</span>, <span class="hljs-number">2</span>),
      },
    ],
  };
}
</code></pre>
<p>Once the token is verified, the server updates the specified to-do in the database, using <code>COALESCE</code> to leave any fields unchanged if no new value is provided. The <code>updated_at</code> timestamp is refreshed automatically.</p>
<p>If the to-do doesn’t exist or the user doesn’t have permission to modify it, the tool returns an error message. Otherwise, it responds with the updated to-do in a clean JSON format:</p>
<p><strong>8.6. Delete To-Do Tool</strong></p>
<p>The <code>delete_todo</code> tool allows an authenticated user to remove a specific to-do. It first checks for a valid token and decodes it to identify the user. If the token is missing or invalid, the tool instructs the user to log in again.</p>
<p>Here’s the handler:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">case</span> <span class="hljs-string">'delete_todo'</span>: {
  <span class="hljs-keyword">const</span> token = getStoredToken();
  <span class="hljs-keyword">if</span> (!token) {
    <span class="hljs-keyword">return</span> {
      content: [
        {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
          text: <span class="hljs-string">'❌ No authentication token found. Please login first.'</span>,
        },
      ],
    ];
  }

  <span class="hljs-keyword">const</span> decoded = decodeJWT(token);
  <span class="hljs-keyword">if</span> (!decoded || !decoded.sub) {
    <span class="hljs-keyword">return</span> {
      content: [
        {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
          text: <span class="hljs-string">'❌ Invalid token. Please login again.'</span>,
        },
      ],
    ];
  }

  <span class="hljs-keyword">const</span> { todoId } = args <span class="hljs-keyword">as</span> { todoId: <span class="hljs-built_in">number</span> };

  <span class="hljs-keyword">const</span> result = <span class="hljs-keyword">await</span> sql<span class="hljs-string">`
    DELETE FROM todos 
    WHERE id = <span class="hljs-subst">${todoId}</span> AND user_id = <span class="hljs-subst">${decoded.sub}</span>
    RETURNING *
  `</span>;

  <span class="hljs-keyword">if</span> (result.length === <span class="hljs-number">0</span>) {
    <span class="hljs-keyword">return</span> {
      content: [
        {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
          text: <span class="hljs-string">'❌ Todo not found or you do not have permission to delete it.'</span>,
        },
      ],
    ];
  }

  <span class="hljs-keyword">return</span> {
    content: [
      {
        <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
        text: <span class="hljs-built_in">JSON</span>.stringify({
          success: <span class="hljs-literal">true</span>,
          message: <span class="hljs-string">'Todo deleted successfully'</span>,
          deletedTodo: result[<span class="hljs-number">0</span>]
        }, <span class="hljs-literal">null</span>, <span class="hljs-number">2</span>),
      },
    ],
  };
}
</code></pre>
<p>This ensures users can only delete their own tasks, keeps the response structured for easy consumption in Cursor, and provides immediate confirmation of the deletion.</p>
<p><strong>8.7. Refresh Billing Status Tool</strong></p>
<p>The <code>refresh_billing_status</code> tool allows an authenticated user to force a fresh check of their billing status from Kinde. It first verifies that a valid token exists and decodes it to identify the user. If the token is missing or invalid, the tool instructs the user to log in again.</p>
<p>Here’s the handler:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">case</span> <span class="hljs-string">'refresh_billing_status'</span>: {
  <span class="hljs-keyword">const</span> token = getStoredToken();
  <span class="hljs-keyword">if</span> (!token) {
    <span class="hljs-keyword">return</span> {
      content: [
        {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
          text: <span class="hljs-string">'❌ No authentication token found. Please login first.'</span>,
        },
      ],
    ];
  }

  <span class="hljs-keyword">const</span> decoded = decodeJWT(token);
  <span class="hljs-keyword">if</span> (!decoded || !decoded.sub) {
    <span class="hljs-keyword">return</span> {
      content: [
        {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
          text: <span class="hljs-string">'❌ Invalid token. Please login again.'</span>,
        },
      ],
    ];
  }

  <span class="hljs-built_in">console</span>.log(<span class="hljs-string">'🔄 Force refreshing billing status for user:'</span>, decoded.sub);
  <span class="hljs-keyword">const</span> billingStatus = <span class="hljs-keyword">await</span> getKindeBillingStatus(decoded.sub, token);

  <span class="hljs-keyword">return</span> {
    content: [
      {
        <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
        text: <span class="hljs-built_in">JSON</span>.stringify({
          success: <span class="hljs-literal">true</span>,
          message: <span class="hljs-string">'Billing status refreshed successfully!'</span>,
          kindeBilling: {
            plan: billingStatus.plan,
            features: billingStatus.features,
            canCreate: billingStatus.canCreate,
            reason: billingStatus.reason,
            upgradeUrl: <span class="hljs-string">'&lt;https://learnflowai.kinde.com/portal&gt;'</span>,
            selfServicePortal: <span class="hljs-string">'&lt;https://learnflowai.kinde.com/portal&gt;'</span>,
            lastChecked: <span class="hljs-keyword">new</span> <span class="hljs-built_in">Date</span>().toISOString()
          }
        }, <span class="hljs-literal">null</span>, <span class="hljs-number">2</span>),
      },
    ],
  };
}
</code></pre>
<p>This tool ensures that users always have an up-to-date view of their subscription status and usage limits, providing all necessary information to decide whether they need to upgrade their plan.</p>
<p><strong>8.8. Logout Tool</strong></p>
<p>The <code>logout</code> tool lets a user end their session by clearing the locally stored authentication token. When called, it checks if the token file exists and deletes it. If successful, the tool confirms that the user has been logged out.</p>
<p>Here’s the handler:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">case</span> <span class="hljs-string">'logout'</span>: {
  <span class="hljs-keyword">try</span> {
    <span class="hljs-keyword">if</span> (fs.existsSync(TOKEN_FILE)) {
      fs.unlinkSync(TOKEN_FILE);
    }
    <span class="hljs-keyword">return</span> {
      content: [
        {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
          text: <span class="hljs-string">'✅ Logged out successfully. Authentication token cleared.'</span>,
        },
      ],
    ];
  } <span class="hljs-keyword">catch</span> (error) {
    <span class="hljs-keyword">return</span> {
      content: [
        {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
          text: <span class="hljs-string">'⚠️ Logout completed, but there was an issue clearing the token file.'</span>,
        },
      ],
    };
  }
}
</code></pre>
<p>This ensures that the session ends cleanly, preventing further use of the MCP server until the user logs in again. It also provides immediate feedback so the user knows the logout process succeeded or if there were minor issues.</p>
<h3 id="heading-9-full-serverts-file">9. Full <code>server.ts</code> File</h3>
<p>You can view the complete implementation of the <code>server.ts</code> file in the <a target="_blank" href="https://github.com/sholajegede/todo_mcp_server/blob/main/src/server.ts">GitHub repo</a> and copy it directly into your project.</p>
<h3 id="heading-10-data-flow-amp-integration">10. Data Flow &amp; Integration</h3>
<p>Now that you’ve wired up authentication, database persistence, and billing checks, let’s step back and look at how everything fits together.</p>
<p>Authentication Flow:</p>
<ul>
<li><p>User clicks <strong>Login with Kinde</strong>.</p>
</li>
<li><p>They are redirected to the <strong>Kinde Auth URL.</strong></p>
</li>
<li><p>Kinde issues a <strong>JWT token</strong> after successful login.</p>
</li>
<li><p>The user copies this token and runs <em>“save_token: &lt;your-jwt-token&gt;”</em> in the Cursor Chat to store it.</p>
</li>
<li><p>The token is stored in the user’s session.</p>
</li>
<li><p>All future requests include the token for validation.</p>
</li>
</ul>
<p>To-do Flow:</p>
<ul>
<li><p>User sends a request (for example, to <em>“create todo”</em> to create a new todo).</p>
</li>
<li><p>The server checks the session for a valid token.</p>
</li>
<li><p>The server verifies the user’s billing plan and usage limits.</p>
</li>
<li><p>If valid, the request hits the <strong>Neon Postgres database</strong>.</p>
</li>
<li><p>Usage counters are updated and a success response is returned.</p>
</li>
</ul>
<p>Complete Data Flow:</p>
<pre><code class="lang-markdown">User Input 
   → MCP Server 
<span class="hljs-code">      → Authentication Check
         → Billing Check
            → Database Operation
               → Response</span>
</code></pre>
<p>Diagram:</p>
<pre><code class="lang-mermaid">flowchart LR
    A[User Input] --&gt; B[MCP Server]
    B --&gt; C{Authenticated?}
    C -- No --&gt; D[Reject Request]
    C -- Yes --&gt; E{Billing OK?}
    E -- No --&gt; F[Reject Request]
    E -- Yes --&gt; G[Database Operation]
    G --&gt; H[Update Usage + Return Response]
</code></pre>
<p>This overview shows how the different components work together. Each feature you’ve added (authentication, billing, and persistence) acts as a checkpoint in the request flow.</p>
<h3 id="heading-11-error-handling-amp-security">11. Error Handling &amp; Security</h3>
<p>Authentication Security:</p>
<ul>
<li><p>JWT Validation: Every request validates JWT token</p>
</li>
<li><p>User Isolation: Users can only access their own to-dos</p>
</li>
<li><p>Token Storage: Tokens are stored locally, not in your database</p>
</li>
</ul>
<p>Database Security:</p>
<ul>
<li><p>SQL Injection Prevention: Your MCP server uses parameterized queries</p>
</li>
<li><p>User Scoping: All queries are filtered by <code>user_id</code></p>
</li>
<li><p>Permission Checks: Every operation validates user ownership</p>
</li>
</ul>
<p>Error Handling:</p>
<ul>
<li><code>try/catch</code> returns safe error messages</li>
</ul>
<h3 id="heading-12-testing-amp-deployment">12. Testing &amp; Deployment</h3>
<p>Finally, start the MCP server:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">main</span>(<span class="hljs-params"></span>) </span>{
  <span class="hljs-keyword">const</span> transport = <span class="hljs-keyword">new</span> StdioServerTransport();
  <span class="hljs-keyword">await</span> server.connect(transport);
  <span class="hljs-built_in">console</span>.error(<span class="hljs-string">'Todo MCP server running on stdio'</span>);
}

main().catch(<span class="hljs-function">(<span class="hljs-params">error</span>) =&gt;</span> {
  <span class="hljs-built_in">console</span>.error(<span class="hljs-string">'Fatal error in main():'</span>, error);
  process.exit(<span class="hljs-number">1</span>);
});
</code></pre>
<p>Test it by running:</p>
<pre><code class="lang-powershell">npm run dev
</code></pre>
<p>In Cursor, you can now try commands like:</p>
<pre><code class="lang-markdown">login
save<span class="hljs-emphasis">_token: <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">your-jwt-token</span>&gt;</span></span>
list todos
create todo
refresh billing status
logout</span>
</code></pre>
<h2 id="heading-testing-the-complete-system"><strong>Testing the Complete System</strong></h2>
<h3 id="heading-1-start-the-services">1. Start the Services</h3>
<pre><code class="lang-powershell"><span class="hljs-comment"># Terminal 1: Start MCP Server</span>
npm run dev

<span class="hljs-comment"># Terminal 2: Start Kinde Auth Server</span>
npm run auth<span class="hljs-literal">-server</span>
</code></pre>
<h3 id="heading-2-configure-cursor-mcp">2. Configure Cursor MCP</h3>
<p>In your Cursor project:</p>
<ul>
<li><p>Go to Settings → Tools &amp; MCP → New MCP Server</p>
</li>
<li><p>Edit the <code>~/.cursor/mcp.json</code> and paste this code below</p>
</li>
</ul>
<pre><code class="lang-json">{
  <span class="hljs-attr">"mcpServers"</span>: {
    <span class="hljs-attr">"todo-mcp-server"</span>: {
      <span class="hljs-attr">"command"</span>: <span class="hljs-string">"node"</span>,
      <span class="hljs-attr">"args"</span>: [<span class="hljs-string">"dist/server.js"</span>],
      <span class="hljs-attr">"cwd"</span>: <span class="hljs-string">"/path/to/your/todo-mcp-server"</span>,
      <span class="hljs-attr">"env"</span>: {
        <span class="hljs-attr">"DATABASE_URL"</span>: <span class="hljs-string">"your-neon-connection-string"</span>,
        <span class="hljs-attr">"KINDE_ISSUER_URL"</span>: <span class="hljs-string">"&lt;https://your-domain.kinde.com&gt;"</span>,
        <span class="hljs-attr">"KINDE_CLIENT_ID"</span>: <span class="hljs-string">"your-client-id"</span>,
        <span class="hljs-attr">"KINDE_CLIENT_SECRET"</span>: <span class="hljs-string">"your-client-secret"</span>,
        <span class="hljs-attr">"JWT_SECRET"</span>: <span class="hljs-string">"your-jwt-secret-key"</span>,
        <span class="hljs-attr">"NODE_ENV"</span>: <span class="hljs-string">"development"</span>
      }
    }
  }
}
</code></pre>
<h3 id="heading-3-test-the-complete-flow">3. Test the Complete Flow</h3>
<p>Open your Cursor chat window and test MCP commands:</p>
<ul>
<li><p><em>login</em> → Get authentication URL</p>
</li>
<li><p><em>save_token</em> → Save your token gotten from Kinde</p>
</li>
<li><p><em>list to-dos</em> → List to-dos</p>
</li>
<li><p><em>create to-do</em> - Create a to-do</p>
</li>
<li><p><em>refresh billing status</em> - Check billing</p>
</li>
</ul>
<h2 id="heading-troubleshooting">Troubleshooting</h2>
<p>Even with everything set up correctly, you might run into issues. Here are some common problems and how to fix them.</p>
<h3 id="heading-1-mcp-server-not-detected">1. MCP Server Not Detected</h3>
<p>If Cursor can’t see your server:</p>
<ul>
<li><p>Double-check the syntax of your <code>~/.cursor/mcp.json</code> file.</p>
</li>
<li><p>Make sure all file paths in <code>mcp.json</code> are <strong>absolute paths</strong> (not relative).</p>
</li>
<li><p>Restart Cursor after making changes to the config file.</p>
</li>
</ul>
<h3 id="heading-2-database-connection-issues">2. Database Connection Issues</h3>
<p>If your Neon database won’t connect:</p>
<ul>
<li><p>Confirm your <code>DATABASE_URL</code> environment variable is correctly formatted.</p>
</li>
<li><p>Log into the <a target="_blank" href="https://console.neon.tech">Neon dashboard</a> and make sure your database is active and not paused.</p>
</li>
<li><p>If you’re using SSL, verify that the SSL mode matches Neon’s connection settings.</p>
</li>
</ul>
<h3 id="heading-3-kinde-authentication-problems">3. Kinde Authentication Problems</h3>
<p>If login isn’t working as expected:</p>
<ul>
<li><p>In your <a target="_blank" href="https://app.kinde.com/admin">Kinde dashboard</a>, make sure the redirect URLs are set correctly (for example, <a target="_blank" href="http://localhost:3000"><code>http://localhost:3000</code></a>).</p>
</li>
<li><p>Double-check that your client ID and client secret are correct.</p>
</li>
<li><p>Ensure your auth server is running locally on port <code>3000</code> before attempting login.</p>
</li>
</ul>
<h3 id="heading-4-token-errors">4. Token Errors</h3>
<p>If you’re getting token-related errors:</p>
<ul>
<li><p>Confirm the token you’re saving is in JWT format (three dot-separated parts).</p>
</li>
<li><p>Make sure the token hasn’t expired.</p>
</li>
<li><p>Use the ID token provided by Kinde, not the access token.</p>
</li>
</ul>
<p>Following these steps should resolve most issues you’ll run into when setting up your MCP server with Cursor, Neon, and Kinde.</p>
<h2 id="heading-final-mcp-server-architecture">Final MCP Server Architecture</h2>
<pre><code class="lang-markdown">┌─────────────────┐    ┌──────────────────┐    ┌─────────────────┐
│   Cursor IDE    │    │   MCP Server     │    │  Kinde Auth     │
│                 │◄──►│                  │◄──►│   Server        │
│ - MCP Tools     │    │ - Todo CRUD      │    │ - OAuth Flow    │
│ - Chat Interface│    │ - Billing Check  │    │ - Token Storage │
└─────────────────┘    └──────────────────┘    └─────────────────┘
<span class="hljs-code">                                │
                                ▼
                       ┌─────────────────┐
                       │ Neon PostgreSQL │
                       │                 │
                       │ - Users Table   │
                       │ - Todos Table   │
                       │ - Billing Data  │
                       └─────────────────┘</span>
</code></pre>
<h2 id="heading-conclusion">Conclusion</h2>
<p>You’ve just built a fully functional MCP server with:</p>
<ul>
<li><p><strong>Authentication</strong> → secure logins with Kinde</p>
</li>
<li><p><strong>Data persistence</strong> → to-dos stored in Neon</p>
</li>
<li><p><strong>Billing enforcement</strong> → usage limits + upgrade path</p>
</li>
<li><p><strong>Tool exposure</strong> → MCP tools accessible in Cursor</p>
</li>
</ul>
<p>This foundation is flexible enough to power more advanced apps while keeping the core flow simple and secure.</p>
<h3 id="heading-next-steps">Next Steps</h3>
<p>Here are some ideas to extend what you’ve built:</p>
<ul>
<li><p><strong>Role-based access control (RBAC):</strong> create admin vs normal user permissions (see my <a target="_blank" href="https://dev.to/sholajegede/part-1-master-authentication-and-role-based-access-control-rbac-with-kinde-and-convex-in-a-h3c">two-part RBAC guide</a> for reference).</p>
</li>
<li><p><strong>Billing tiers:</strong> offer free, pro, and enterprise plans with different limits.</p>
</li>
<li><p><strong>Features:</strong> add search, tags, or sharing to to-dos.</p>
</li>
<li><p><strong>Deployment:</strong> run the service on a cloud platform with HTTPS and a production-grade database.</p>
</li>
</ul>
<h3 id="heading-resources">Resources</h3>
<p>You can find the complete source code for this tutorial in <a target="_blank" href="https://github.com/sholajegede/todo_mcp_server">this GitHub repository</a>. If it helped you in any way, consider giving it a star (⭐) to show your support!</p>
<p>Also, if you found this tutorial valuable, feel free to share it with others who might benefit from it. I’d really appreciate your thoughts, you can mention me on X <a target="_blank" href="https://x.com/wani_shola">@wani_shola</a> or <a target="_blank" href="https://www.linkedin.com/in/sholajegede">connect with me on LinkedIn</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Use the ChatGPT Apps SDK: Build a Pizza App with Apps SDK ]]>
                </title>
                <description>
                    <![CDATA[ OpenAI recently introduced ChatGPT Apps, powered by the new Apps SDK and the Model Context Protocol (MCP). Think of these apps as plugins for ChatGPT: You can invoke them naturally in a conversation. They can render custom interactive UIs inside Ch... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-use-the-chatgpt-apps-sdk/</link>
                <guid isPermaLink="false">68efe8d446ad0f2d5932c5e1</guid>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ openai ]]>
                    </category>
                
                    <category>
                        <![CDATA[ chatgpt ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Shola Jegede ]]>
                </dc:creator>
                <pubDate>Wed, 15 Oct 2025 18:32:52 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1760552846436/808fcd59-4dbc-4874-bd62-2e13965f956c.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>OpenAI recently introduced ChatGPT Apps, powered by the new <a target="_blank" href="https://developers.openai.com/apps-sdk">Apps SDK</a> and the Model Context Protocol (MCP).</p>
<p>Think of these apps as plugins for ChatGPT:</p>
<ul>
<li><p>You can invoke them naturally in a conversation.</p>
</li>
<li><p>They can render custom interactive UIs inside ChatGPT (maps, carousels, videos, and more).</p>
</li>
<li><p>They run on an MCP server that you control, which defines the tools, resources, and widgets the app provides.</p>
</li>
</ul>
<p>In this step-by-step guide, you’ll build a ChatGPT App using the official <a target="_blank" href="https://github.com/openai/openai-apps-sdk-examples/tree/main/pizzaz_server_node">Pizza App example</a>. This app shows how ChatGPT can render UI widgets like a pizza map or carousel, powered by your local server.</p>
<h2 id="heading-what-youll-learn">What You’ll Learn</h2>
<p>By following this tutorial, you’ll learn how to:</p>
<ul>
<li><p>Set up and run a ChatGPT App with the OpenAI Apps SDK.</p>
</li>
<li><p>Understand the core building blocks: tools, resources, and widgets.</p>
</li>
<li><p>Connect your local app server to ChatGPT using Developer Mode.</p>
</li>
<li><p>Render custom UI directly inside a ChatGPT conversation.</p>
</li>
</ul>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a class="post-section-overview" href="#heading-what-youll-learn">What You’ll Learn</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-table-of-contents">Table of Contents</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-chatgpt-apps-work-big-picture">How ChatGPT Apps Work (Big Picture)</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-1-clone-the-examples-repo">Step 1. Clone the Examples Repo</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-2-run-the-pizza-app-server">Step 2. Run the Pizza App Server</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-3-expose-your-local-server">Step 3. Expose Your Local Server</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-31-get-ngrok">3.1 Get ngrok</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-32-install-ngrok">3.2 Install ngrok</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-33-connect-your-account">3.3 Connect Your Account</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-34-start-a-tunnel">3.4 Start a Tunnel</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-step-4-walk-through-the-pizza-app-code">Step 4. Walk Through the Pizza App Code</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-41-imports-and-setup">4.1 Imports and Setup</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-42-defining-pizza-widgets">4.2 Defining Pizza Widgets</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-43-mapping-widgets-to-tools-and-resources">4.3 Mapping Widgets to Tools and Resources</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-44-handling-requests">4.4 Handling Requests</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-45-creating-the-server">4.5 Creating the Server</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-step-5-enable-developer-mode-in-chatgpt">Step 5. Enable Developer Mode in ChatGPT</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-51-enable-developer-mode">5.1 Enable Developer Mode</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-52-create-app">5.2 Create App</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-53-use-your-app">5.3 Use Your App</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-challenges-try-these-yourself">Challenges (Try These Yourself)</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-challenge-a-add-a-pizza-specials-widget-text-only">Challenge A: Add a “Pizza Specials” widget (text-only)</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-challenge-b-support-multiple-toppings">Challenge B: Support Multiple Toppings</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-challenge-c-fetch-real-pizza-data-from-an-external-api">Challenge C: Fetch Real Pizza Data from an External API</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-how-chatgpt-apps-work-big-picture">How ChatGPT Apps Work (Big Picture)</h2>
<p>Here’s the architecture in simple terms:</p>
<pre><code class="lang-markdown">ChatGPT (frontend)
   |
   v
MCP Server (your backend)
   |
   v
Widgets (HTML/JS markup displayed inside ChatGPT)
</code></pre>
<ul>
<li><p><strong>ChatGPT</strong> sends requests like: <em>“Show me a pizza carousel.”</em></p>
</li>
<li><p><strong>MCP Server</strong> responds with resources (HTML markup) and tool logic.</p>
</li>
<li><p><strong>Widgets</strong> are rendered inline in ChatGPT.</p>
</li>
</ul>
<h2 id="heading-step-1-clone-the-examples-repo">Step 1. Clone the Examples Repo</h2>
<p>OpenAI provides an official examples repo that includes the Pizza App. Clone it and install the dependencies using these commands:</p>
<pre><code class="lang-powershell">git clone https://github.com/openai/openai<span class="hljs-literal">-apps</span><span class="hljs-literal">-sdk</span><span class="hljs-literal">-examples</span>.git
<span class="hljs-built_in">cd</span> openai<span class="hljs-literal">-apps</span><span class="hljs-literal">-sdk</span><span class="hljs-literal">-examples</span>
pnpm install
</code></pre>
<p>After installing, build the components and start the dev server:</p>
<pre><code class="lang-powershell">pnpm run build  
pnpm run dev
</code></pre>
<h2 id="heading-step-2-run-the-pizza-app-server">Step 2. Run the Pizza App Server</h2>
<p>Navigate to the Pizza App server and start it:</p>
<pre><code class="lang-powershell"><span class="hljs-built_in">cd</span> pizzaz_server_node
pnpm <span class="hljs-built_in">start</span>
</code></pre>
<p>If it works, you should see:</p>
<pre><code class="lang-powershell">Pizzaz MCP server listening on http://localhost:<span class="hljs-number">8000</span>
  SSE stream: GET http://localhost:<span class="hljs-number">8000</span>/mcp
  Message post endpoint: POST http://localhost:<span class="hljs-number">8000</span>/mcp/messages
</code></pre>
<p>This means your server is running locally.</p>
<h2 id="heading-step-3-expose-your-local-server">Step 3. Expose Your Local Server</h2>
<p>To let ChatGPT communicate with your app, your local server needs a public URL. ngrok provides a quick way to expose it during development.</p>
<h3 id="heading-31-get-ngrok">3.1 Get ngrok</h3>
<p>Sign up at <a target="_blank" href="https://ngrok.com">ngrok.com</a> and copy your <strong>authtoken</strong>.</p>
<h3 id="heading-32-install-ngrok">3.2 Install ngrok</h3>
<p><strong>macOS:</strong></p>
<pre><code class="lang-powershell">brew install ngrok
</code></pre>
<p><strong>Windows:</strong></p>
<ul>
<li><p>Download and unzip ngrok.</p>
</li>
<li><p>Optionally, add the folder to your PATH.</p>
</li>
</ul>
<h3 id="heading-33-connect-your-account">3.3 Connect Your Account</h3>
<pre><code class="lang-powershell">ngrok config <span class="hljs-built_in">add-authtoken</span> &lt;your_authtoken&gt;
</code></pre>
<h3 id="heading-34-start-a-tunnel">3.4 Start a Tunnel</h3>
<pre><code class="lang-powershell">ngrok http <span class="hljs-number">8000</span>
</code></pre>
<p>This gives you a public HTTPS URL (like <a target="_blank" href="https://xyz.ngrok.app/mcp"><code>https://xyz.ngrok.app/mcp</code></a>).</p>
<h2 id="heading-step-4-walk-through-the-pizza-app-code">Step 4. Walk Through the Pizza App Code</h2>
<p>The full Pizza App server code is long, so let’s break it down into digestible parts.</p>
<h3 id="heading-41-imports-and-setup">4.1 Imports and Setup</h3>
<pre><code class="lang-typescript"><span class="hljs-keyword">import</span> { createServer } <span class="hljs-keyword">from</span> <span class="hljs-string">"node:http"</span>;
<span class="hljs-keyword">import</span> { Server } <span class="hljs-keyword">from</span> <span class="hljs-string">"@modelcontextprotocol/sdk/server/index.js"</span>;
<span class="hljs-keyword">import</span> { SSEServerTransport } <span class="hljs-keyword">from</span> <span class="hljs-string">"@modelcontextprotocol/sdk/server/sse.js"</span>;
<span class="hljs-keyword">import</span> { z } <span class="hljs-keyword">from</span> <span class="hljs-string">"zod"</span>;
</code></pre>
<ul>
<li><p><code>Server</code> and <code>SSEServerTransport</code> come from the Apps SDK.</p>
</li>
<li><p><code>zod</code> validates input to ensure ChatGPT sends the right arguments.</p>
</li>
</ul>
<h3 id="heading-42-defining-pizza-widgets">4.2 Defining Pizza Widgets</h3>
<p>Widgets are the heart of the app. Each one represents a piece of UI ChatGPT can display.</p>
<p>Here’s the Pizza Map widget:</p>
<pre><code class="lang-typescript">{
  id: <span class="hljs-string">"pizza-map"</span>,
  title: <span class="hljs-string">"Show Pizza Map"</span>,
  templateUri: <span class="hljs-string">"ui://widget/pizza-map.html"</span>,
  html: <span class="hljs-string">`
    &lt;div id="pizzaz-root"&gt;&lt;/div&gt;
    &lt;link rel="stylesheet" href=".../pizzaz-0038.css"&gt;
    &lt;script type="module" src=".../pizzaz-0038.js"&gt;&lt;/script&gt;
  `</span>,
  responseText: <span class="hljs-string">"Rendered a pizza map!"</span>
}
</code></pre>
<ul>
<li><p><code>id</code> → unique name of the widget.</p>
</li>
<li><p><code>templateUri</code> → how ChatGPT fetches the UI.</p>
</li>
<li><p><code>html</code> → actual markup and assets.</p>
</li>
<li><p><code>responseText</code> → message that shows in chat.</p>
</li>
</ul>
<p>The app defines five widgets:</p>
<ul>
<li><p>Pizza Map</p>
</li>
<li><p>Pizza Carousel</p>
</li>
<li><p>Pizza Album</p>
</li>
<li><p>Pizza List</p>
</li>
<li><p>Pizza Video</p>
</li>
</ul>
<h3 id="heading-43-mapping-widgets-to-tools-and-resources">4.3 Mapping Widgets to Tools and Resources</h3>
<p>Next, widgets are converted into <strong>tools</strong> (things ChatGPT can call) and <strong>resources</strong> (UI markup ChatGPT can render).</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">const</span> tools = widgets.map(<span class="hljs-function">(<span class="hljs-params">widget</span>) =&gt;</span> ({
  name: widget.id,
  description: widget.title,
  inputSchema: toolInputSchema,
  title: widget.title,
  _meta: widgetMeta(widget)
}));

<span class="hljs-keyword">const</span> resources = widgets.map(<span class="hljs-function">(<span class="hljs-params">widget</span>) =&gt;</span> ({
  uri: widget.templateUri,
  name: widget.title,
  description: <span class="hljs-string">`<span class="hljs-subst">${widget.title}</span> widget markup`</span>,
  mimeType: <span class="hljs-string">"text/html+skybridge"</span>,
  _meta: widgetMeta(widget)
}));
</code></pre>
<p>This makes each widget callable and displayable.</p>
<h3 id="heading-44-handling-requests">4.4 Handling Requests</h3>
<p>The MCP server responds to ChatGPT’s requests. For example, when ChatGPT calls a widget tool:</p>
<pre><code class="lang-typescript">server.setRequestHandler(CallToolRequestSchema, <span class="hljs-keyword">async</span> (request) =&gt; {
  <span class="hljs-keyword">const</span> widget = widgetsById.get(request.params.name);
  <span class="hljs-keyword">const</span> args = toolInputParser.parse(request.params.arguments ?? {});
  <span class="hljs-keyword">return</span> {
    content: [{ <span class="hljs-keyword">type</span>: <span class="hljs-string">"text"</span>, text: widget.responseText }],
    structuredContent: { pizzaTopping: args.pizzaTopping },
    _meta: widgetMeta(widget)
  };
});
</code></pre>
<p>This:</p>
<ul>
<li><p>Finds the widget requested.</p>
</li>
<li><p>Validates the input (<code>pizzaTopping</code>).</p>
</li>
<li><p>Responds with text + metadata so ChatGPT can render the widget.</p>
</li>
</ul>
<h3 id="heading-45-creating-the-server">4.5 Creating the Server</h3>
<p>Finally, the server is bound to HTTP endpoints (<code>/mcp</code> and <code>/mcp/messages</code>) so ChatGPT can stream messages to and from it.</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">const</span> httpServer = createServer(<span class="hljs-keyword">async</span> (req, res) =&gt; {
  <span class="hljs-comment">// handle requests to /mcp and /mcp/messages</span>
});

httpServer.listen(<span class="hljs-number">8000</span>, <span class="hljs-function">() =&gt;</span> {
  <span class="hljs-built_in">console</span>.log(<span class="hljs-string">"Pizzaz MCP server running on port 8000"</span>);
});
</code></pre>
<h2 id="heading-step-5-enable-developer-mode-in-chatgpt">Step 5. Enable Developer Mode in ChatGPT</h2>
<h3 id="heading-51-enable-developer-mode">5.1 Enable Developer Mode</h3>
<ul>
<li><p>Open ChatGPT</p>
</li>
<li><p>Go to <strong>Settings → Apps &amp; Connectors → Advanced Settings</strong></p>
</li>
<li><p>Toggle <strong>Developer Mode</strong></p>
</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1760313826734/7cf96d44-ae03-48d1-92b9-7fee42d895ad.png" alt="Toggle developer mode" class="image--center mx-auto" width="1560" height="1402" loading="lazy"></p>
<p>When <strong>Developer Mode</strong> is enabled, ChatGPT should look like this:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1760313206155/f2677b50-8bc0-4c10-b971-b0a60d66181f.png" alt="Developer mode enabled" class="image--center mx-auto" width="1622" height="602" loading="lazy"></p>
<h3 id="heading-52-create-app">5.2 Create App</h3>
<ul>
<li><p>Go back to <strong>Settings → Apps &amp; Connectors</strong></p>
</li>
<li><p>Click <strong>Create</strong></p>
</li>
<li><p>Next:</p>
<ul>
<li><p><strong>Name</strong>: Enter a name for your app (for example, <em>Pizza App</em>)</p>
</li>
<li><p><strong>Description</strong>: Enter any description for your app (or leave empty)</p>
</li>
<li><p><strong>MCP Server URL</strong>: Paste the public HTTPS URL of your MCP endpoint. Make sure it points directly to <code>/mcp</code>, not just the server root</p>
</li>
<li><p><strong>Authentication</strong>: Choose <strong>No authentication</strong></p>
</li>
<li><p>Check <strong>I trust this application</strong></p>
</li>
<li><p>Click <strong>Create</strong> to finish</p>
</li>
</ul>
</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1760313317398/93d30263-59db-4606-8066-467b7949efb9.png" alt="Create your app in ChatGPT" class="image--center mx-auto" width="1626" height="1598" loading="lazy"></p>
<p>Once your app is connected to ChatGPT, it should look like this:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1760313733914/944b363a-7004-4737-a102-bd1e328f717d.png" alt="App is connected to ChatGPT" class="image--center mx-auto" width="1542" height="1446" loading="lazy"></p>
<p>When you click on the <strong>Back</strong> icon, you should see your app and other apps that you can connect to and use with ChatGPT:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1760313649918/627594a8-a89b-4cd5-90a6-1fa2d804063e.png" alt="View all apps that can be connected to ChatGPT" class="image--center mx-auto" width="1624" height="1460" loading="lazy"></p>
<h3 id="heading-53-use-your-app">5.3 Use Your App</h3>
<p>To use your app,</p>
<ul>
<li><p>Open a new chat in ChatGPT</p>
</li>
<li><p>Click on the <strong>+</strong> icon</p>
</li>
<li><p>Scroll down to <strong>more</strong></p>
</li>
<li><p>You would see your app</p>
</li>
<li><p>Choose <strong>Pizza App</strong> to start using your app</p>
</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1760313495700/e978f689-622b-4ceb-aa73-6459302e8b3b.png" alt="How to use your app in ChatGPT" class="image--center mx-auto" width="1626" height="1288" loading="lazy"></p>
<p>Here are some commands you can try out with your pizza app in ChatGPT:</p>
<ul>
<li><p><em>Show me a pizza map with pepperoni topping</em></p>
</li>
<li><p><em>Show me a pizza carousel with mushroom topping</em></p>
</li>
<li><p><em>Show me a pizza album with veggie topping</em></p>
</li>
<li><p><em>Show me a pizza list with cheese topping</em></p>
</li>
<li><p><em>Show me a pizza video with chicken topping</em></p>
</li>
</ul>
<p>Each command tells ChatGPT which widget to render, and you can swap in any topping you like.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1760313589161/07b69ab7-fd36-4a14-84de-883a0f634b82.png" alt="Type in a command into ChatGPT to make tool calls to your app" class="image--center mx-auto" width="1622" height="688" loading="lazy"></p>
<p>Below are samples:</p>
<ul>
<li>Pepperoni topping map:</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1760314642952/6527fe96-061b-433c-94b9-86b8152fd082.png" alt="Sample app response: Pepperoni topping map" class="image--center mx-auto" width="1886" height="1504" loading="lazy"></p>
<ul>
<li>Extra cheese carousel:</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1760314675799/8b65e9b3-4547-40a2-9269-cec56aa8705f.png" alt="Sample app response: Extra cheese carousel" class="image--center mx-auto" width="2108" height="1292" loading="lazy"></p>
<ul>
<li>Mushroom topping album:</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1760314714658/ae6edf57-44c9-421b-a140-364cc8873db4.png" alt="Sample app response: Mushroom topping album" class="image--center mx-auto" width="3348" height="1992" loading="lazy"></p>
<h2 id="heading-challenges-try-these-yourself">Challenges (Try These Yourself)</h2>
<p>Here are three practical ways to extend your Pizza App. Each one ties directly to the code you already have.</p>
<h3 id="heading-challenge-a-add-a-pizza-specials-widget-text-only">Challenge A: Add a “Pizza Specials” widget (text-only)</h3>
<p><strong>Goal:</strong> Create a widget that just shows a short message like <em>“Today’s special: Margherita with basil.”</em></p>
<p><strong>Where to change:</strong></p>
<ul>
<li><p><code>resources.widgets</code> → duplicate an entry and give it a new <code>id</code>/<code>title</code>.</p>
</li>
<li><p><code>tools</code> → register it as a new tool.</p>
</li>
<li><p><code>CallTool</code> handler → detect when it’s called (<code>if (request.params.name === "pizza-special")</code>) and return your special.</p>
</li>
</ul>
<p><strong>Hint:</strong><br>This widget doesn’t need extra CSS/JS files. Just keep its <code>html</code> to something like <code>&lt;div&gt;🍕 Today’s special: Margherita&lt;/div&gt;</code>. The idea is to show that widgets can be as simple as plain HTML.</p>
<h3 id="heading-challenge-b-support-multiple-toppings">Challenge B: Support Multiple Toppings</h3>
<p><strong>Goal:</strong> Let users order a pizza with more than one topping, like <code>["pepperoni", "mushroom"]</code>.</p>
<p><strong>Where to change:</strong></p>
<ul>
<li><p><code>toolInputSchema</code> → switch from <code>z.string()</code> to <code>z.array(z.string())</code>.</p>
</li>
<li><p><code>CallTool</code> handler → after parsing, <code>args.pizzaTopping</code> will be an array. Join it into a string before inserting into HTML/response.</p>
</li>
<li><p>Widget HTML → update the display so it lists all chosen toppings.</p>
</li>
</ul>
<p><strong>Hint:</strong><br>Console.log the parsed <code>args</code> first to confirm you’re actually getting an array. Then try something like:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">const</span> toppings = args.pizzaTopping.join(<span class="hljs-string">", "</span>);
<span class="hljs-keyword">return</span> { responseText: <span class="hljs-string">`Pizza ordered with <span class="hljs-subst">${toppings}</span>`</span> };
</code></pre>
<h3 id="heading-challenge-c-fetch-real-pizza-data-from-an-external-api">Challenge C: Fetch Real Pizza Data from an External API</h3>
<p><strong>Goal:</strong> Instead of hard-coding content, fetch real pizza info. For example, you could call Yelp’s API to list pizza places in a location, or use a free placeholder API to simulate data.</p>
<p><strong>Where to change:</strong></p>
<ul>
<li><p>Inside the <code>CallTool</code> handler for your widget.</p>
</li>
<li><p>Replace the static HTML with a <code>fetch(...)</code> call that builds dynamic HTML from the response.</p>
</li>
</ul>
<p><strong>Hint:</strong><br>Start small with a free API like <a target="_blank" href="https://jsonplaceholder.typicode.com/posts">JSONPlaceholder</a>. For example:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">const</span> res = <span class="hljs-keyword">await</span> fetch(<span class="hljs-string">"https://jsonplaceholder.typicode.com/posts?_limit=3"</span>);
<span class="hljs-keyword">const</span> data = <span class="hljs-keyword">await</span> res.json();

<span class="hljs-keyword">const</span> html = <span class="hljs-string">`
  &lt;ul&gt;
    <span class="hljs-subst">${data.map((p: <span class="hljs-built_in">any</span>) =&gt; <span class="hljs-string">`&lt;li&gt;<span class="hljs-subst">${p.title}</span>&lt;/li&gt;`</span>).join(<span class="hljs-string">""</span>)}</span>
  &lt;/ul&gt;
`</span>;

<span class="hljs-keyword">return</span> { responseText: <span class="hljs-string">"Fetched pizza places!"</span>, content: [{ <span class="hljs-keyword">type</span>: <span class="hljs-string">"text/html"</span>, text: html }] };
</code></pre>
<p>Once that works, swap in a real API such as Yelp or Google Maps Places to render actual pizza places.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>You just built your first ChatGPT App using the <strong>OpenAI Apps SDK</strong>. With a bit of JavaScript and HTML, you created a server that ChatGPT can talk to, and rendered interactive widgets right inside the chat window.</p>
<p>This example focused on the pizza app sample provided by OpenAI, but you could build:</p>
<ul>
<li><p>A weather dashboard,</p>
</li>
<li><p>A movie finder,</p>
</li>
<li><p>A financial data viewer,</p>
</li>
<li><p>Or even a mini-game.</p>
</li>
</ul>
<p>The SDK makes it possible to blend <strong>conversation + interactive UI</strong> in powerful new ways.</p>
<p>Explore the <a target="_blank" href="https://developers.openai.com/apps-sdk">OpenAI Apps SDK documentation</a> to go deeper and start building your own apps.</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
