diff --git a/.gitignore b/.gitignore
index d1188c04..5af477c6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -36,3 +36,10 @@ __pycache__/
# Ruff
.ruff_cache/
+
+# AI Config
+.claude/
+CLAUDE.md
+
+# ngrok
+ngrok.yml
\ No newline at end of file
diff --git a/README.md b/README.md
index d1943697..7b70edbe 100644
--- a/README.md
+++ b/README.md
@@ -227,9 +227,10 @@ Include this in the environment variables:
```
BASE_URL=https://your-server.com
+API_BASE_URL=https://your-api.example.com
```
-This will be used to generate the HTML for the widgets so that they can serve static assets from this hosted url.
+This will be used to generate the HTML for the widgets so that they can serve static assets from this hosted url. `API_BASE_URL` is used by client widgets to build fully-qualified API URLs (for example, the Cards Against AI game event stream).
## Contributing
diff --git a/build-all.mts b/build-all.mts
index 3b3a9711..b3f050bc 100644
--- a/build-all.mts
+++ b/build-all.mts
@@ -1,4 +1,5 @@
import { build, type InlineConfig, type Plugin } from "vite";
+import dotenv from "dotenv";
import react from "@vitejs/plugin-react";
import fg from "fast-glob";
import path from "path";
@@ -7,6 +8,8 @@ import crypto from "crypto";
import pkg from "./package.json" with { type: "json" };
import tailwindcss from "@tailwindcss/vite";
+dotenv.config({ path: path.resolve(process.cwd(), ".env.local") });
+
const entries = fg.sync("src/**/index.{tsx,jsx}");
const outDir = "assets";
@@ -17,6 +20,7 @@ const GLOBAL_CSS_LIST = [path.resolve("src/index.css")];
const targets: string[] = [
"todo",
"solar-system",
+ "cards-against-ai",
"pizzaz",
"pizzaz-carousel",
"pizzaz-list",
@@ -27,6 +31,13 @@ const targets: string[] = [
"kitchen-sink-lite",
"shopping-cart",
];
+const cliTargetIndex = process.argv.indexOf("--target");
+const cliTarget = cliTargetIndex !== -1 ? process.argv[cliTargetIndex + 1] : null;
+if (cliTarget) {
+ targets.length = 0;
+ targets.push(cliTarget);
+}
+
const builtNames: string[] = [];
function wrapEntryPlugin(
@@ -168,11 +179,31 @@ console.groupEnd();
console.log("new hash: ", h);
const defaultBaseUrl = "http://localhost:4444";
-const baseUrlCandidate = process.env.BASE_URL?.trim() ?? "";
+const baseUrlCandidate = (
+ process.env.VITE_BASE_URL ??
+ process.env.BASE_URL ??
+ ""
+).trim();
const baseUrlRaw = baseUrlCandidate.length > 0 ? baseUrlCandidate : defaultBaseUrl;
const normalizedBaseUrl = baseUrlRaw.replace(/\/+$/, "") || defaultBaseUrl;
console.log(`Using BASE_URL ${normalizedBaseUrl} for generated HTML`);
+const defaultApiBaseUrl = "http://localhost:8000";
+const apiBaseUrlCandidate = (
+ process.env.VITE_API_BASE_URL ??
+ process.env.API_BASE_URL ??
+ ""
+).trim();
+const apiBaseUrlRaw =
+ apiBaseUrlCandidate.length > 0 ? apiBaseUrlCandidate : defaultApiBaseUrl;
+const normalizedApiBaseUrl =
+ apiBaseUrlRaw.replace(/\/+$/, "") || defaultApiBaseUrl;
+const appUrlConfigJson = JSON.stringify({
+ apiBaseUrl: normalizedApiBaseUrl,
+ assetsBaseUrl: normalizedBaseUrl,
+});
+console.log(`Using API_BASE_URL ${normalizedApiBaseUrl} for generated HTML`);
+
for (const name of builtNames) {
const dir = outDir;
const hashedHtmlPath = path.join(dir, `${name}-${h}.html`);
@@ -180,6 +211,9 @@ for (const name of builtNames) {
const html = `
+
diff --git a/cards_against_ai_server_node/ANSWER_DECK_GUIDANCE.md b/cards_against_ai_server_node/ANSWER_DECK_GUIDANCE.md
new file mode 100644
index 00000000..915d1358
--- /dev/null
+++ b/cards_against_ai_server_node/ANSWER_DECK_GUIDANCE.md
@@ -0,0 +1,23 @@
+Cards Against AI Answer Deck Guidance
+
+Goal
+Create an answer deck with at least 100 AnswerCards. Each answer card is a short,
+punchy phrase meant to complete or respond to a prompt card in a funny way.
+
+Tone and Content
+- Aim for humor: clever, absurd, irreverent, or unexpected.
+- Keep it short: usually a word or short phrase (1-8 words).
+- Use a mix of styles: wordplay, deadpan, pop culture, current events, and
+ relatable everyday situations.
+- Include variety: people, places, objects, actions, abstract ideas, and
+ references.
+- Avoid slurs or hateful content; edgy is fine, harmful is not.
+
+Relevance Tips
+- Sprinkle in recent or evergreen pop culture references.
+- Include a few topical references that would feel current to most players.
+- Balance niche references with broad ones so most prompts have good options.
+
+Usage
+These cards are dealt to players and submitted to the judge each round. A strong
+deck makes it easy to pick funny responses across many prompts.
diff --git a/cards_against_ai_server_node/README.md b/cards_against_ai_server_node/README.md
new file mode 100644
index 00000000..c3057797
--- /dev/null
+++ b/cards_against_ai_server_node/README.md
@@ -0,0 +1,43 @@
+# Cards Against AI — MCP Server (Node)
+
+MCP Apps backend that drives a card game through ChatGPT's model while keeping a real-time widget in sync. Single server serves both MCP API and widget assets.
+
+## Quick Start
+
+```bash
+pnpm install # from repo root
+cd cards_against_ai_server_node
+pnpm run dev # builds widget + starts server on :8000
+```
+
+The server serves widget assets and MCP endpoint from the same port (8000).
+
+### With ngrok
+
+```bash
+echo 'BASE_URL=https://your-domain.ngrok.app' > .env.local # in repo root
+ngrok http 8000 --domain your-domain.ngrok.app
+cd cards_against_ai_server_node && pnpm run dev
+```
+
+Single tunnel, single server.
+
+### Scripts
+
+| Script | Description |
+|--------|-------------|
+| `pnpm run dev` | Build widget + start server |
+| `pnpm run build` | Build widget only |
+| `pnpm run build:check` | Build widget + typecheck app + server |
+| `pnpm start` | Start server (no build) |
+
+## Key MCP Apps Concepts
+
+- **Tool response structure** — `buildGameToolResponse` shows the three data channels: `_meta` (widget binding), `content` (model-visible text), and `structuredContent` (widget-visible data).
+- **Widget session binding** — `openai/widgetSessionId` ties all tool responses to the same widget iframe. Without it, each tool call spawns a new widget.
+- **Resource registration** — Widget HTML is served as an MCP resource so ChatGPT can render it. CSP metadata controls which domains the sandboxed iframe can access.
+- **Rules resources** — `rules://` URIs provide context documents the model reads before acting. They inform behavior, not UI.
+- **Tool annotations** — `toolAnnotations` hint to ChatGPT whether to show confirmation dialogs (readOnlyHint, destructiveHint, openWorldHint).
+- **Stateless transport** — `createCardsAgainstAiServer` creates a fresh McpServer per request. Game state lives in a Map, not in the MCP session.
+- **SSE for real-time updates** — Custom SSE endpoint pushes game state to the widget on every mutation, separate from the MCP protocol.
+- **Zod input schemas** — `registerAppTool` accepts Zod shapes, not JSON Schema. The SDK converts them automatically.
diff --git a/cards_against_ai_server_node/RULES.md b/cards_against_ai_server_node/RULES.md
new file mode 100644
index 00000000..8251f92c
--- /dev/null
+++ b/cards_against_ai_server_node/RULES.md
@@ -0,0 +1,223 @@
+# Cards Against AI
+
+Cards Against AI is an irreverent adult party game designed to produce humorous,
+often offensive or politically incorrect, combinations of phrases. It relies on
+subjective humor rather than strategy.
+
+## Card Types
+
+**Black Cards (Prompts)**: These contain a question or a fill-in-the-blank statement.
+The blank is represented by four underscores (____).
+
+**White Cards (Answers)**: These contain a noun or phrase used to answer or complete
+the prompt on the black card.
+
+## Win Condition
+
+**First to 5 wins!** The game ends when any player reaches 5 "Awesome Points"
+(won prompt cards).
+
+## Game Flow
+
+1. **Start Game**: ChatGPT generates 4 players (1 human + 3 CPU), 7 answer cards
+ per player (28 total), the first prompt card, and intro dialog.
+
+2. **Each Round**:
+ - Judge reveals prompt
+ - **Human player plays their answer card first**
+ - Then CPU players choose and play their answer cards
+ - Judge picks the funniest (wins the prompt card)
+ - Winner gets 1 point
+ - Judge rotates to next player
+
+3. **Between Rounds**: ChatGPT provides:
+ - New prompt card text
+ - Replacement answer cards (1 per player who played last round)
+
+## Human as Judge
+
+When the human player is the judge for a round:
+- Do NOT mention the human's hand cards — they only judge, they don't play.
+- Replacement cards go only to players who played last round. The judge did not play and must NOT receive one.
+- When `nextAction` is `play-cpu-answer-cards` after `submit-prompt`, call it immediately using the `cpuContext` from the response.
+
+## Tool Response Format
+
+Every tool response includes:
+- `structuredContent.nextAction`: A hint telling ChatGPT what tool to call next
+- `structuredContent.gameState`: The full current game state (plus `gameId` and `gameKey`)
+
+Use `nextAction.action` to determine the next step:
+- `"play-cpu-answer-cards"` — CPU players need to play cards. Use `play-cpu-answer-cards` tool.
+- `"cpu-judge-answer-card"` — CPU judge needs to pick the winner. Use `cpu-judge-answer-card` tool.
+- `"human-answer-pending"` — Waiting for human to play a card
+- `"human-judge-pending"` — Waiting for human to judge
+- `"wait-for-next-round"` — Round complete, wait for human to click "Next Round"
+- `"submit-prompt"` — Submit a new prompt and replacement cards
+- `"game-over"` — Game has ended
+
+`nextAction.notifyModel` indicates whether the widget will automatically route the action through the model.
+
+## MCP Tool Schemas
+
+### start-game
+
+Creates a new game instance.
+
+```json
+{
+ "players": [
+ {
+ "id": "string",
+ "name": "string",
+ "type": "human" | "cpu",
+ "persona": { ... },
+ "answerCards": [
+ { "id": "string", "type": "answer", "text": "string" }
+ ]
+ }
+ ],
+ "firstPrompt": "string",
+ "introDialog": [
+ {
+ "playerId": "string",
+ "playerName": "string",
+ "dialog": "string"
+ }
+ ]
+}
+```
+
+**Response textContent**: Role-played introductions from CPU characters.
+
+### play-answer-card
+
+Human player plays an answer card from their hand.
+
+```json
+{
+ "gameId": "string",
+ "playerId": "string",
+ "cardId": "string"
+}
+```
+
+### judge-answer-card
+
+Human judge picks the winning answer card.
+
+```json
+{
+ "gameId": "string",
+ "playerId": "string",
+ "winningCardId": "string"
+}
+```
+
+### play-cpu-answer-cards
+
+Submit CPU player card selections. Read CPU persona details and card hands from
+`structuredContent.cpuContext` in the previous response.
+
+```json
+{
+ "gameId": "string",
+ "cpuAnswerChoices": [
+ {
+ "playerId": "string",
+ "cardId": "string",
+ "playerComment": "string"
+ }
+ ]
+}
+```
+
+**Response textContent**: CPU quips. If `nextAction` is `cpu-judge-answer-card`, call that tool immediately.
+
+### cpu-judge-answer-card
+
+Submit the CPU judge's verdict. Read the played answer cards from `structuredContent.cpuContext`.
+
+```json
+{
+ "gameId": "string",
+ "winningCardId": "string",
+ "reactionToWinningCard": "string"
+}
+```
+
+**Response textContent**: Judge announcement.
+
+### submit-prompt
+
+Provides next round's prompt and replacement cards.
+
+```json
+{
+ "gameId": "string",
+ "promptText": "string",
+ "replacementCards": [
+ {
+ "playerId": "string",
+ "card": { "id": "string", "type": "answer", "text": "string" }
+ }
+ ]
+}
+```
+
+## In-Character Dialog
+
+Every response MUST include in-character dialog from CPU players. Write it directly in your response text — do NOT use a separate tool.
+
+- 1-2 sentences per character, max. Not everyone speaks every time.
+- Use persona fields (personality, humorStyle, catchphrase, quirks, voiceTone, competitiveness)
+- ~70% game reactions, ~30% personality tangents. Characters reference each other.
+- **Reference the human player too** — address them by name (the human player's name is in gameState.players where type === "human"), tease their card choices, react to their judging, etc. Make them feel like part of the table.
+- Format: **Name**: "dialog" or **Name** *action*: "dialog"
+
+## TextContent Format
+
+CPU tool responses include role-played textContent (quips/announcements)
+that ChatGPT should display to create an immersive experience:
+
+```markdown
+**Brenda the Soccer Mom** slaps down a card:
+"Oh, this one's going to get me banned from the PTA."
+
+**Dave from IT** carefully places his card:
+"Statistically, this has a 23% chance of being funny."
+```
+
+## Persona Schema (CPU Required)
+
+```json
+{
+ "id": "string",
+ "name": "string",
+ "personality": "string",
+ "likes": ["string"],
+ "dislikes": ["string"],
+ "humorStyle": ["string"],
+ "favoriteJokeTypes": ["string"],
+ "catchphrase": "string (optional — signature phrase)",
+ "quirks": ["string (optional — behavioral tics)"],
+ "backstory": "string (optional — 1-2 sentence background)",
+ "voiceTone": "string (optional — e.g. 'sarcastic', 'deadpan')",
+ "competitiveness": "number 1-10 (optional — trash-talk intensity)"
+}
+```
+
+## Chat Narration
+
+Write CPU character dialog directly in your response text. Let the characters speak — don't summarize. Dialog should never delay the next tool call.
+
+## Standard Rules Reference
+
+Initial Setup: Each player gets 7 answer cards.
+Role Designation: First player is the initial judge (Card Czar).
+The Prompt: The judge reveals a prompt card.
+Submission: The human player plays their answer card first, then CPU players choose their cards.
+Judging: The judge picks their favorite response.
+The Winner: The winning player keeps the prompt card (1 point).
+Reset: Players draw replacement cards. Judge rotates to next player.
+Ending: First to 5 points wins!
diff --git a/cards_against_ai_server_node/cards-against-ai-mcp-node@0.1.0 b/cards_against_ai_server_node/cards-against-ai-mcp-node@0.1.0
new file mode 100644
index 00000000..e69de29b
diff --git a/cards_against_ai_server_node/package.json b/cards_against_ai_server_node/package.json
new file mode 100644
index 00000000..8eeb871d
--- /dev/null
+++ b/cards_against_ai_server_node/package.json
@@ -0,0 +1,27 @@
+{
+ "name": "cards-against-ai-mcp-node",
+ "version": "0.1.0",
+ "type": "module",
+ "private": true,
+ "description": "MCP server that exposes the Cards Against AI widget.",
+ "scripts": {
+ "build": "cd .. && pnpm run build -- --target cards-against-ai",
+ "build:check": "cd .. && pnpm run build -- --target cards-against-ai && npx tsc -p tsconfig.app.json --noEmit && npx tsc -p cards_against_ai_server_node/tsconfig.json --noEmit",
+ "dev": "cd .. && pnpm run build -- --target cards-against-ai && cd cards_against_ai_server_node && tsx src/server.ts",
+ "start": "tsx src/server.ts"
+ },
+ "dependencies": {
+ "@modelcontextprotocol/ext-apps": "^1.0.1",
+ "@modelcontextprotocol/sdk": "^1.26.0",
+ "cors": "^2.8.5",
+ "dotenv": "^17.2.3",
+ "express": "^5.2.1",
+ "zod": "^3.25.0"
+ },
+ "devDependencies": {
+ "@types/cors": "^2.8.17",
+ "@types/express": "^5.0.0",
+ "tsx": "^4.19.2",
+ "typescript": "^5.6.3"
+ }
+}
diff --git a/cards_against_ai_server_node/src/GameInstance.ts b/cards_against_ai_server_node/src/GameInstance.ts
new file mode 100644
index 00000000..0606b648
--- /dev/null
+++ b/cards_against_ai_server_node/src/GameInstance.ts
@@ -0,0 +1,707 @@
+import { EventEmitter } from "node:events";
+import {
+ AnswerCard,
+ ChatMessage,
+ GameState,
+ JudgementResult,
+ NextActionHint,
+ Persona,
+ Player,
+ PromptCard,
+} from "./shared-types.js";
+
+interface InitializeNewGameAction {
+ type: "INITIALIZE_NEW_GAME";
+ players: PlayerInput[];
+ firstPrompt: string;
+}
+
+interface DealReplacementCardsAction {
+ type: "DEAL_REPLACEMENT_CARDS";
+ replacementCards: Array<{ playerId: string; card: AnswerCard }>;
+}
+
+interface JudgingAction {
+ type: "JUDGING";
+}
+
+interface AnnounceWinnerAction {
+ type: "ANNOUNCE_WINNER";
+ winnerId: string;
+}
+
+interface ReturnJudgementAction {
+ type: "RETURN_JUDGEMENT";
+ result: {
+ judgeId: string;
+ winningCardId: string;
+ winningPlayerId: string;
+ reactionToWinningCard?: string;
+ };
+}
+
+interface PromptReceivedAction {
+ type: "PROMPT_RECEIVED";
+ prompt: PromptCard;
+}
+
+interface PrepareForNextRoundAction {
+ type: "PREPARE_FOR_NEXT_ROUND";
+}
+
+interface PlayerPlayedAnswerCardAction {
+ type: "PLAYER_PLAYED_ANSWER_CARD";
+ playerId: string;
+ cardId: string;
+ playerComment?: string;
+}
+
+interface PostBanterAction {
+ type: "POST_BANTER";
+ messages: ChatMessage[];
+}
+
+type GameAction =
+ | InitializeNewGameAction
+ | DealReplacementCardsAction
+ | JudgingAction
+ | ReturnJudgementAction
+ | PromptReceivedAction
+ | PrepareForNextRoundAction
+ | AnnounceWinnerAction
+ | PlayerPlayedAnswerCardAction
+ | PostBanterAction;
+
+interface PlayerInput {
+ id: string;
+ name: string;
+ type: "human" | "cpu";
+ persona: Persona | null;
+ answerCards: AnswerCard[];
+}
+
+interface GameInstanceOptions {
+ players: PlayerInput[];
+ firstPrompt: string;
+}
+
+export class GameInstance extends EventEmitter {
+ /** A unique key for the game instance. This can be used later to join the game. */
+ readonly key = generateKey();
+ private readonly options: GameInstanceOptions;
+
+ private state: GameState = {
+ gameKey: this.key,
+ prompt: null,
+ playedAnswerCards: [],
+ players: [],
+ status: "initializing",
+ currentJudgePlayerIndex: 0,
+ answerCards: {},
+ discardedPromptCards: [],
+ judgementResult: null,
+ winnerId: null,
+ chatLog: [],
+ };
+
+ constructor(options: GameInstanceOptions) {
+ super();
+ this.options = options;
+ }
+
+ getState(): GameState {
+ return this.state;
+ }
+
+ getNonJudgeHandTexts(): string[] {
+ const judge = this.state.players[this.state.currentJudgePlayerIndex] ?? null;
+ const texts: string[] = [];
+
+ for (const player of this.state.players) {
+ if (player.id === judge?.id) {
+ continue;
+ }
+ for (const cardId of player.answerCards) {
+ const card = this.state.answerCards[cardId];
+ if (card) {
+ texts.push(card.text);
+ }
+ }
+ }
+
+ return texts;
+ }
+
+ initializeNewGame() {
+ this.dispatchAction({
+ type: "INITIALIZE_NEW_GAME",
+ players: this.options.players,
+ firstPrompt: this.options.firstPrompt,
+ });
+ }
+
+ playAnswerCard(playerId: string, cardId: string, playerComment?: string) {
+ const player = this.state.players.find((entry) => entry.id === playerId);
+ if (!player) {
+ throw new Error(`Player ${playerId} not found`);
+ }
+ const judge = this.state.players[this.state.currentJudgePlayerIndex];
+ if (judge?.id === playerId) {
+ throw new Error(`Judge ${playerId} cannot play an answer card`);
+ }
+ if (this.state.status !== "waiting-for-answers") {
+ throw new Error(
+ `Cannot play answer card while game is ${this.state.status}`,
+ );
+ }
+ if (
+ this.state.playedAnswerCards.some(
+ (played) => played.playerId === playerId,
+ )
+ ) {
+ // Idempotent: already played, return silently
+ return;
+ }
+ if (!player.answerCards.includes(cardId)) {
+ throw new Error(
+ `Player ${playerId} does not have this card in their hand`,
+ );
+ }
+
+ this.dispatchAction({
+ type: "PLAYER_PLAYED_ANSWER_CARD",
+ playerId,
+ cardId,
+ playerComment,
+ });
+
+ // Auto-advance to judging if all cards are in
+ if (this.state.playedAnswerCards.length === this.getExpectedAnswerCount()) {
+ this.dispatchAction({ type: "JUDGING" });
+ }
+ }
+
+ /**
+ * Submit CPU answer card choices from ChatGPT.
+ */
+ submitCpuAnswers(choices: Array<{ playerId: string; cardId: string; playerComment?: string }>) {
+ if (this.state.status !== "waiting-for-answers") {
+ throw new Error(
+ `Cannot submit CPU answers while game is ${this.state.status}`,
+ );
+ }
+ const judge = this.state.players[this.state.currentJudgePlayerIndex];
+ const cpuPlayers = this.state.players.filter(
+ (player) => player.type === "cpu" && player.id !== judge?.id,
+ );
+
+ const choicesByPlayerId = new Map();
+ for (const choice of choices) {
+ choicesByPlayerId.set(choice.playerId, choice);
+ }
+
+ for (const player of cpuPlayers) {
+ if (this.state.playedAnswerCards.some((played) => played.playerId === player.id)) {
+ continue;
+ }
+
+ const choice = choicesByPlayerId.get(player.id);
+ let cardIdToPlay: string | null = choice?.cardId ?? null;
+
+ if (!cardIdToPlay || !player.answerCards.includes(cardIdToPlay)) {
+ cardIdToPlay = pickRandomAnswerCardId(player.answerCards);
+ }
+
+ if (!cardIdToPlay) {
+ continue;
+ }
+
+ const comment = sanitizeCpuComment(choice?.playerComment, player.persona?.name);
+ this.playAnswerCard(player.id, cardIdToPlay, comment);
+ }
+ }
+
+ /**
+ * Submit CPU judgement from ChatGPT.
+ */
+ submitCpuJudgement(result: { winningCardId: string; reactionToWinningCard?: string }) {
+ if (this.state.status !== "judging") {
+ throw new Error(
+ `Cannot submit CPU judgement while game is ${this.state.status}`,
+ );
+ }
+ const playedAnswerCards = this.state.playedAnswerCards;
+
+ let winningCardId = result.winningCardId;
+ if (!findPlayedAnswerCard(playedAnswerCards, winningCardId)) {
+ winningCardId = pickRandomPlayedCardId(playedAnswerCards) ?? winningCardId;
+ }
+
+ const winningEntry = findPlayedAnswerCard(playedAnswerCards, winningCardId);
+ if (!winningEntry) {
+ throw new Error("CPU judgement winning card not found in played answers");
+ }
+
+ const judge = this.state.players[this.state.currentJudgePlayerIndex];
+ if (!judge) {
+ throw new Error("No judge found");
+ }
+
+ const reaction = sanitizeCpuReaction(
+ result.reactionToWinningCard,
+ judge.persona?.name,
+ );
+ this.judgeAnswers({
+ judgeId: judge.id,
+ winningCardId,
+ winningPlayerId: winningEntry.playerId,
+ reactionToWinningCard: reaction,
+ });
+ }
+
+ /**
+ * Submit a prompt card from ChatGPT along with replacement cards.
+ * Internally calls prepareForNextRound first, then sets the new prompt.
+ */
+ submitPrompt(promptText: string, replacementCards?: Array<{ playerId: string; card: AnswerCard }>) {
+ if (this.state.status !== "display-judgement" && this.state.status !== "prepare-for-next-round") {
+ throw new Error(
+ `Cannot submit prompt while game is ${this.state.status}. Check nextAction for the correct tool to call.`,
+ );
+ }
+ // Prepare for next round (clear played cards, rotate judge, etc.)
+ this.dispatchAction({ type: "PREPARE_FOR_NEXT_ROUND" });
+
+ // Deal replacement cards only to players who actually played (hand < 7 cards)
+ // Re-key cards server-side to avoid duplicates from model-generated IDs
+ if (replacementCards && replacementCards.length > 0) {
+ const usedIds = new Set(Object.keys(this.state.answerCards));
+ const filtered = replacementCards
+ .filter((rc) => {
+ const player = this.state.players.find((p) => p.id === rc.playerId);
+ return player && player.answerCards.length < 7;
+ })
+ .map((rc) => {
+ let id = rc.card.id;
+ if (usedIds.has(id)) {
+ id = `ans-${crypto.randomUUID().slice(0, 8)}`;
+ }
+ usedIds.add(id);
+ return { ...rc, card: { ...rc.card, id } };
+ });
+ if (filtered.length > 0) {
+ this.dispatchAction({ type: "DEAL_REPLACEMENT_CARDS", replacementCards: filtered });
+ }
+ }
+
+ const prompt: PromptCard = {
+ id: `prompt-${crypto.randomUUID()}`,
+ type: "prompt",
+ text: promptText.trim(),
+ };
+ this.dispatchAction({ type: "PROMPT_RECEIVED", prompt });
+ }
+
+ /**
+ * Append banter messages to the chat log.
+ */
+ addBanter(messages: ChatMessage[]) {
+ this.dispatchAction({ type: "POST_BANTER", messages });
+ }
+
+ /**
+ * Builds the data package sent to the model when it needs to make CPU
+ * decisions. Includes CPU player hands, the current prompt, already-played
+ * cards, and judge info — everything the model needs to play in-character.
+ * This is included in `structuredContent` and `content` (assistant-only)
+ * when `nextAction.notifyModel` is true.
+ */
+ getCpuContext() {
+ const judge = this.state.players[this.state.currentJudgePlayerIndex] ?? null;
+
+ const cpuPlayers = this.state.players
+ .filter(
+ (player) =>
+ player.type === "cpu" &&
+ player.id !== judge?.id &&
+ !this.state.playedAnswerCards.some(
+ (played) => played.playerId === player.id,
+ ),
+ )
+ .map((player) => ({
+ id: player.id,
+ name: player.persona?.name ?? "CPU",
+ persona: player.persona,
+ hand: player.answerCards
+ .map((cardId) => {
+ const card = this.state.answerCards[cardId];
+ return card ? { id: card.id, text: card.text } : null;
+ })
+ .filter((card): card is { id: string; text: string } => card !== null),
+ }));
+
+ const playedAnswers = this.state.playedAnswerCards.map((played) => {
+ const card = this.state.answerCards[played.cardId];
+ return {
+ cardId: played.cardId,
+ text: card?.text ?? "",
+ };
+ });
+
+ return {
+ prompt: this.state.prompt ? { text: this.state.prompt.text } : null,
+ cpuPlayers,
+ playedAnswers: playedAnswers.length > 0 ? playedAnswers : undefined,
+ previousPromptTexts: this.state.discardedPromptCards.map((p) => p.text),
+ handTexts: this.getNonJudgeHandTexts(),
+ judge: judge ? { id: judge.id, name: judge.persona?.name ?? "Unknown" } : null,
+ };
+ }
+
+ /**
+ * The routing brain. Returns a NextActionHint that tells the widget and
+ * model what should happen next:
+ * - `notifyModel: true` → model needs to act (CPU plays, CPU judges).
+ * The widget will sendMessage to prompt the model.
+ * - `notifyModel: false` → waiting for human input (play card, judge,
+ * click "Next Round"). The widget just waits.
+ */
+ computeNextAction(): NextActionHint {
+ const { status, players, currentJudgePlayerIndex, playedAnswerCards } = this.state;
+ const judge = players[currentJudgePlayerIndex] ?? null;
+
+ if (status === "announce-winner" || status === "game-ended") {
+ const winner = players.find((p) => p.id === this.state.winnerId);
+ return {
+ action: "game-over",
+ description: `Game over! ${winner?.persona?.name ?? "Someone"} wins with ${winner?.wonPromptCards.length ?? 0} points.`,
+ notifyModel: false,
+ };
+ }
+
+ if (status === "waiting-for-answers") {
+ // Human plays FIRST
+ const humanPlayerPending = players.some(
+ (p) =>
+ p.type === "human" &&
+ p.id !== judge?.id &&
+ !playedAnswerCards.some((played) => played.playerId === p.id),
+ );
+
+ if (humanPlayerPending) {
+ return {
+ action: "human-answer-pending",
+ description: "Waiting for the human player to play an answer card.",
+ notifyModel: false,
+ };
+ }
+
+ // CPU players need to play
+ const cpuPlayersWhoNeedToPlay = players.filter(
+ (p) =>
+ p.type === "cpu" &&
+ p.id !== judge?.id &&
+ !playedAnswerCards.some((played) => played.playerId === p.id),
+ );
+
+ if (cpuPlayersWhoNeedToPlay.length > 0) {
+ const judgeIsHuman = judge?.type === "human";
+ let description = "CPU players need to play answer cards.";
+ if (judgeIsHuman) {
+ description += " The human player is the judge this round — do NOT mention their hand cards. They will judge in the widget after cards are played.";
+ }
+ description += " Use the play-cpu-answer-cards tool now.";
+ return { action: "play-cpu-answer-cards", description, notifyModel: true };
+ }
+
+ return null;
+ }
+
+ if (status === "judging") {
+ if (judge?.type === "cpu") {
+ return {
+ action: "cpu-judge-answer-card",
+ description: `${judge.persona?.name ?? "CPU judge"} needs to pick the winning card. Use the cpu-judge-answer-card tool now.`,
+ notifyModel: true,
+ };
+ }
+
+ return {
+ action: "human-judge-pending",
+ description: "Waiting for the human player to judge the cards.",
+ notifyModel: false,
+ };
+ }
+
+ if (status === "display-judgement") {
+ // After judgement, check for winner
+ const winner = players.find((p) => p.wonPromptCards.length >= 5);
+ if (winner) {
+ return {
+ action: "game-over",
+ description: `${winner.persona?.name ?? "Someone"} has won the game with ${winner.wonPromptCards.length} points!`,
+ notifyModel: false,
+ };
+ }
+
+ return {
+ action: "wait-for-next-round",
+ description: "Round complete. Wait for the human to click 'Next Round' before submitting a new prompt.",
+ notifyModel: false,
+ };
+ }
+
+ if (status === "prepare-for-next-round") {
+ return {
+ action: "submit-prompt",
+ description: "Submit a new prompt card and replacement answer cards for the next round.",
+ notifyModel: false,
+ };
+ }
+
+ return null;
+ }
+
+ judgeAnswers(result: JudgementResult) {
+ // Idempotent: if not in judging state, return silently
+ if (this.state.status !== "judging") {
+ return;
+ }
+
+ const currentJudge = this.state.players[this.state.currentJudgePlayerIndex];
+ if (!currentJudge || currentJudge.id !== result.judgeId) {
+ throw new Error(`Player ${result.judgeId} is not the current judge`);
+ }
+
+ this.dispatchAction({ type: "RETURN_JUDGEMENT", result });
+
+ // Check for winner (first to 5 wins)
+ const winner = this.state.players.find((p) => p.wonPromptCards.length >= 5);
+ if (winner) {
+ this.dispatchAction({ type: "ANNOUNCE_WINNER", winnerId: winner.id });
+ }
+ }
+
+ private reducer(prevState: GameState, action: GameAction): GameState {
+ switch (action.type) {
+ case "INITIALIZE_NEW_GAME": {
+ // Build answerCards map from all player hands
+ const answerCards: Record = {};
+ for (const playerInput of action.players) {
+ for (const card of playerInput.answerCards) {
+ answerCards[card.id] = card;
+ }
+ }
+
+ // Create players from input
+ const players: Player[] = action.players.map((playerInput) => ({
+ id: playerInput.id,
+ type: playerInput.type,
+ persona: playerInput.persona ?? {
+ id: playerInput.id,
+ name: playerInput.name,
+ personality: "",
+ likes: [],
+ dislikes: [],
+ humorStyle: [],
+ favoriteJokeTypes: [],
+ },
+ wonPromptCards: [],
+ answerCards: playerInput.answerCards.map((card) => card.id),
+ }));
+
+ // Create first prompt
+ const firstPrompt: PromptCard = {
+ id: `prompt-${crypto.randomUUID()}`,
+ type: "prompt",
+ text: action.firstPrompt,
+ };
+
+ // Find first CPU player to be judge (human should never judge first)
+ const firstCpuIndex = players.findIndex((p) => p.type === "cpu");
+ const judgeIndex = firstCpuIndex >= 0 ? firstCpuIndex : 0;
+
+ return {
+ ...prevState,
+ status: "waiting-for-answers",
+ players,
+ answerCards,
+ prompt: firstPrompt,
+ currentJudgePlayerIndex: judgeIndex,
+ };
+ }
+ case "DEAL_REPLACEMENT_CARDS": {
+ const newAnswerCards = { ...prevState.answerCards };
+ const updatedPlayers = prevState.players.map((player) => {
+ const replacement = action.replacementCards.find((r) => r.playerId === player.id);
+ if (!replacement) return player;
+ newAnswerCards[replacement.card.id] = replacement.card;
+ return {
+ ...player,
+ answerCards: [...player.answerCards, replacement.card.id],
+ };
+ });
+ return {
+ ...prevState,
+ answerCards: newAnswerCards,
+ players: updatedPlayers,
+ };
+ }
+ case "JUDGING": {
+ return {
+ ...prevState,
+ status: "judging",
+ };
+ }
+ case "RETURN_JUDGEMENT": {
+ const prompt = prevState.prompt;
+ const winningPlayerId = action.result.winningPlayerId;
+ const players = prompt
+ ? prevState.players.map((player) => {
+ if (player.id === winningPlayerId) {
+ return {
+ ...player,
+ wonPromptCards: Array.from(new Set([...player.wonPromptCards, prompt])),
+ };
+ }
+ return player;
+ })
+ : prevState.players;
+ return {
+ ...prevState,
+ status: "display-judgement",
+ judgementResult: action.result,
+ players,
+ };
+ }
+ case "PREPARE_FOR_NEXT_ROUND": {
+ const discardedPromptCards = prevState.prompt
+ ? [...prevState.discardedPromptCards, prevState.prompt]
+ : prevState.discardedPromptCards;
+ return {
+ ...prevState,
+ status: "prepare-for-next-round",
+ playedAnswerCards: [],
+ discardedPromptCards,
+ prompt: null,
+ judgementResult: null,
+ currentJudgePlayerIndex:
+ (prevState.currentJudgePlayerIndex + 1) % prevState.players.length,
+ };
+ }
+ case "ANNOUNCE_WINNER": {
+ return {
+ ...prevState,
+ status: "announce-winner",
+ winnerId: action.winnerId,
+ };
+ }
+ case "PROMPT_RECEIVED": {
+ return {
+ ...prevState,
+ status: "waiting-for-answers",
+ prompt: action.prompt,
+ playedAnswerCards: [],
+ };
+ }
+ case "PLAYER_PLAYED_ANSWER_CARD": {
+ return {
+ ...prevState,
+ playedAnswerCards: [
+ ...prevState.playedAnswerCards,
+ {
+ playerId: action.playerId,
+ cardId: action.cardId,
+ playerComment: action.playerComment,
+ },
+ ],
+ players: prevState.players.map((player) =>
+ player.id === action.playerId
+ ? {
+ ...player,
+ answerCards: player.answerCards.filter(
+ (cardId) => cardId !== action.cardId,
+ ),
+ }
+ : player,
+ ),
+ };
+ }
+ case "POST_BANTER": {
+ return {
+ ...prevState,
+ chatLog: [...prevState.chatLog, ...action.messages],
+ };
+ }
+ default: {
+ return prevState;
+ }
+ }
+ }
+
+ private dispatchAction(action: GameAction) {
+ this.state = this.reducer(this.state, action);
+ this.emit("change", this.state);
+ }
+
+ private getExpectedAnswerCount() {
+ const judge = this.state.players[this.state.currentJudgePlayerIndex];
+ return this.state.players.reduce((count, player) => {
+ if (player.id !== judge?.id) {
+ return count + 1;
+ }
+ return count;
+ }, 0);
+ }
+}
+
+const keyLength = 8;
+function generateKey() {
+ return Math.random()
+ .toString(36)
+ .substring(2, keyLength + 2);
+}
+
+function sanitizeCpuComment(comment: string | undefined, fallbackName?: string | null) {
+ if (typeof comment === "string" && comment.trim().length > 0) {
+ return comment.trim();
+ }
+ const name = fallbackName ?? "CPU";
+ return `${name} is feeling this one.`;
+}
+
+function sanitizeCpuReaction(reaction: string | undefined, fallbackName?: string | null) {
+ if (typeof reaction === "string" && reaction.trim().length > 0) {
+ return reaction.trim();
+ }
+ const name = fallbackName ?? "CPU";
+ return `${name} picks this one.`;
+}
+
+function pickRandomAnswerCardId(answerCards: string[]) {
+ if (!answerCards.length) {
+ return null;
+ }
+ const index = Math.floor(Math.random() * answerCards.length);
+ return answerCards[index];
+}
+
+function pickRandomPlayedCardId(playedAnswerCards: GameState["playedAnswerCards"]) {
+ if (!playedAnswerCards.length) {
+ return null;
+ }
+ const index = Math.floor(Math.random() * playedAnswerCards.length);
+ return playedAnswerCards[index].cardId;
+}
+
+function findPlayedAnswerCard(
+ playedAnswerCards: GameState["playedAnswerCards"],
+ cardId: string,
+) {
+ for (const entry of playedAnswerCards) {
+ if (entry.cardId === cardId) {
+ return entry;
+ }
+ }
+ return null;
+}
diff --git a/cards_against_ai_server_node/src/server.ts b/cards_against_ai_server_node/src/server.ts
new file mode 100644
index 00000000..ad00fb07
--- /dev/null
+++ b/cards_against_ai_server_node/src/server.ts
@@ -0,0 +1,894 @@
+/**
+ * Cards Against AI MCP server (Node).
+ *
+ * Exposes game tools over MCP. All game state flows through tool responses.
+ * Uses McpServer + StreamableHTTP + ext-apps (MCP Apps standard).
+ */
+import { randomUUID } from "node:crypto";
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+import dotenv from "dotenv";
+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
+// ext-apps wrappers that add MCP Apps metadata (widget UI binding, CSP
+// configuration) automatically when registering tools and resources.
+import {
+ registerAppTool,
+ registerAppResource,
+ RESOURCE_MIME_TYPE,
+} from "@modelcontextprotocol/ext-apps/server";
+import { z } from "zod";
+
+import { GameInstance } from "./GameInstance.js";
+import type { IntroDialogEntry } from "./shared-types.js";
+
+// Use express from the SDK's own dependencies
+import express from "express";
+import cors from "cors";
+
+interface GameRecord {
+ id: string;
+ key: string;
+ instance: GameInstance;
+}
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const ROOT_DIR = path.resolve(__dirname, "..", "..");
+const ASSETS_DIR = path.resolve(ROOT_DIR, "assets");
+// `ui://widget/...` tells ChatGPT which widget HTML to render in the iframe.
+// `rules://` URIs are context documents the model reads before acting.
+const TEMPLATE_URI = "ui://widget/cards-against-ai.html";
+const RULES_URI = "rules://cards-against-ai";
+const ANSWER_GUIDANCE_URI = "rules://cards-against-ai/answer-deck";
+const MARKDOWN_MIME_TYPE = "text/markdown";
+const RULES_PATH = path.resolve(
+ ROOT_DIR,
+ "cards_against_ai_server_node",
+ "RULES.md",
+);
+const ANSWER_GUIDANCE_PATH = path.resolve(
+ ROOT_DIR,
+ "cards_against_ai_server_node",
+ "ANSWER_DECK_GUIDANCE.md",
+);
+
+dotenv.config({ path: path.resolve(ROOT_DIR, ".env.local") });
+
+// Single BASE_URL — both assets and API are served from the same origin.
+const BASE_URL = normalizeBaseUrl(
+ process.env.BASE_URL ??
+ process.env.VITE_BASE_URL ??
+ "",
+);
+const BASE_ORIGIN = parseOrigin(BASE_URL);
+
+// The widget runs sandboxed in ChatGPT's iframe. CSP domains whitelist which
+// origins the widget can fetch from: connect for XHR/SSE, resource for scripts/images.
+const OPENAI_ASSETS_ORIGIN = "https://persistent.oaistatic.com";
+const widgetCspDomains = BASE_ORIGIN
+ ? {
+ connectDomains: [BASE_ORIGIN],
+ resourceDomains: [BASE_ORIGIN, OPENAI_ASSETS_ORIGIN],
+ }
+ : {
+ connectDomains: [] as string[],
+ resourceDomains: [OPENAI_ASSETS_ORIGIN],
+ };
+
+const portEnv = Number(process.env.PORT ?? 8000);
+const port = Number.isFinite(portEnv) ? portEnv : 8000;
+
+const gamesById = new Map();
+
+function normalizeBaseUrl(value: string): string | null {
+ const trimmed = value.trim();
+ if (!trimmed) {
+ return null;
+ }
+
+ return trimmed.replace(/\/+$/, "");
+}
+
+function parseOrigin(value: string | null): string | null {
+ if (!value) {
+ return null;
+ }
+
+ try {
+ return new URL(value).origin;
+ } catch {
+ return null;
+ }
+}
+
+function readWidgetHtml(): string {
+ if (!fs.existsSync(ASSETS_DIR)) {
+ throw new Error(
+ `Widget assets not found. Expected directory ${ASSETS_DIR}. Run "pnpm run build" before starting the server.`,
+ );
+ }
+
+ const directPath = path.join(ASSETS_DIR, "cards-against-ai.html");
+ let htmlContents: string | null = null;
+
+ if (fs.existsSync(directPath)) {
+ htmlContents = fs.readFileSync(directPath, "utf8");
+ } else {
+ const candidates = fs
+ .readdirSync(ASSETS_DIR)
+ .filter(
+ (file) =>
+ file.startsWith("cards-against-ai-") && file.endsWith(".html"),
+ )
+ .sort();
+ const fallback = candidates[candidates.length - 1];
+ if (fallback) {
+ htmlContents = fs.readFileSync(path.join(ASSETS_DIR, fallback), "utf8");
+ }
+ }
+
+ if (!htmlContents) {
+ throw new Error(
+ `Widget HTML for "cards-against-ai" not found in ${ASSETS_DIR}. Run "pnpm run build" to generate the assets.`,
+ );
+ }
+
+ const effectiveBaseUrl = BASE_URL ?? `http://localhost:${port}`;
+ return htmlContents
+ .replaceAll("http://localhost:4444", effectiveBaseUrl)
+ .replaceAll("http://localhost:8000", effectiveBaseUrl);
+}
+
+function readMarkdownFile(filePath: string, label: string): string {
+ if (!fs.existsSync(filePath)) {
+ throw new Error(
+ `Cards Against AI ${label} not found. Expected file ${filePath}.`,
+ );
+ }
+
+ return fs.readFileSync(filePath, "utf8");
+}
+
+const widgetHtml = readWidgetHtml();
+const rulesMarkdown = readMarkdownFile(RULES_PATH, "rules");
+const answerGuidanceMarkdown = readMarkdownFile(
+ ANSWER_GUIDANCE_PATH,
+ "answer deck guidance",
+);
+
+// Every tool response includes this so ChatGPT knows which widget to render.
+// `resourceUri` points to the widget HTML registered as an MCP resource.
+const toolUiMeta = {
+ ui: {
+ resourceUri: TEMPLATE_URI,
+ },
+};
+
+// registerAppTool accepts Zod shapes (not JSON Schema objects).
+// The SDK converts these to JSON Schema for the model automatically.
+
+const cpuPersonaParser = z.object({
+ id: z.string(),
+ name: z.string(),
+ personality: z.string(),
+ likes: z.array(z.string()),
+ dislikes: z.array(z.string()),
+ humorStyle: z.array(z.string()),
+ favoriteJokeTypes: z.array(z.string()),
+ catchphrase: z.string().optional(),
+ quirks: z.array(z.string()).optional(),
+ backstory: z.string().optional(),
+ voiceTone: z.string().optional(),
+ competitiveness: z.number().min(1).max(10).optional(),
+});
+
+const answerCardParser = z.object({
+ id: z.string(),
+ type: z.literal("answer"),
+ text: z.string(),
+});
+
+const introDialogEntryParser = z.object({
+ playerId: z.string(),
+ playerName: z.string(),
+ dialog: z.string(),
+});
+
+const playerInputParser = z.object({
+ id: z.string(),
+ name: z.string(),
+ type: z.enum(["human", "cpu"]),
+ persona: cpuPersonaParser.optional(),
+ answerCards: z.array(answerCardParser),
+});
+
+const startGameShape = {
+ players: z.array(playerInputParser).min(4).max(4),
+ firstPrompt: z.string(),
+ introDialog: z.array(introDialogEntryParser),
+};
+
+const playAnswerCardShape = {
+ gameId: z.string(),
+ playerId: z.string(),
+ cardId: z.string(),
+};
+
+const judgeAnswerCardShape = {
+ gameId: z.string(),
+ playerId: z.string(),
+ winningCardId: z.string(),
+};
+
+const playCpuAnswerCardsShape = {
+ gameId: z.string(),
+ cpuAnswerChoices: z.array(
+ z.object({
+ playerId: z.string(),
+ cardId: z.string(),
+ playerComment: z.string().optional(),
+ }),
+ ),
+};
+
+const cpuJudgeAnswerCardShape = {
+ gameId: z.string(),
+ winningCardId: z.string(),
+ reactionToWinningCard: z.string().optional(),
+};
+
+const replacementCardParser = z.object({
+ playerId: z.string(),
+ card: answerCardParser,
+});
+
+const submitPromptShape = {
+ gameId: z.string(),
+ promptText: z.string(),
+ replacementCards: z.array(replacementCardParser),
+};
+
+// --- Game logic helpers ---
+
+/**
+ * Builds a tool response with three data channels:
+ *
+ * 1. `_meta` — widget session binding. `openai/widgetSessionId` ties all tool
+ * responses to the same widget instance. Without it, each tool call would
+ * spawn a new widget iframe.
+ *
+ * 2. `content` — text that appears in the ChatGPT conversation. The JSON blob
+ * uses `annotations.audience: ["assistant"]` to hide it from the user —
+ * only the model sees it (game state, nextAction hints, cpuContext).
+ *
+ * 3. `structuredContent` — typed data channel for the widget. The widget reads
+ * this via `callServerTool` responses and `ontoolresult`. The model does NOT
+ * see structuredContent.
+ */
+function buildGameToolResponse(
+ toolName: string,
+ record: GameRecord,
+ textContent: string,
+) {
+ const nextAction = record.instance.computeNextAction();
+ const cpuContext = nextAction?.notifyModel
+ ? record.instance.getCpuContext()
+ : undefined;
+
+ return {
+ _meta: {
+ ...toolUiMeta,
+ // Binds this response to the existing widget instance for this game.
+ "openai/widgetSessionId": record.id,
+ },
+ // Text content visible in the conversation. The assistant-only JSON blob
+ // gives the model game state and next-action hints without cluttering the
+ // user-visible chat.
+ content: [
+ ...(textContent ? [{ type: "text" as const, text: textContent }] : []),
+ {
+ type: "text" as const,
+ text: JSON.stringify({
+ gameId: record.id,
+ gameKey: record.key,
+ gameState: record.instance.getState(),
+ nextAction,
+ ...(cpuContext ? { cpuContext } : {}),
+ }),
+ annotations: { audience: ["assistant" as const] },
+ },
+ ],
+ // Widget-only data. The widget reads this; the model doesn't see it.
+ structuredContent: {
+ invocation: toolName,
+ gameId: record.id,
+ gameKey: record.key,
+ gameState: record.instance.getState(),
+ nextAction,
+ ...(cpuContext ? { cpuContext } : {}),
+ },
+ };
+}
+
+function gameNotFoundError(toolName: string) {
+ return {
+ _meta: toolUiMeta,
+ isError: true as const,
+ content: [{ type: "text" as const, text: "Unknown game id" }],
+ structuredContent: {
+ invocation: toolName,
+ },
+ };
+}
+
+function getGameRecord(gameId: string) {
+ return gamesById.get(gameId) ?? null;
+}
+
+function formatIntroDialog(introDialog: IntroDialogEntry[]): string {
+ if (introDialog.length === 0) {
+ return "";
+ }
+
+ return introDialog
+ .map((entry) => `**${entry.playerName}**: "${entry.dialog}"`)
+ .join("\n\n");
+}
+
+function formatCpuAnswerQuips(
+ choices: Array<{ playerId: string; cardId: string; playerComment?: string }>,
+ instance: GameInstance,
+): string {
+ const state = instance.getState();
+ const lines: string[] = [];
+
+ for (const choice of choices) {
+ const player = state.players.find((p) => p.id === choice.playerId);
+ const name = player?.persona?.name ?? "CPU";
+ const comment = choice.playerComment?.trim();
+
+ if (comment) {
+ lines.push(`**${name}** slaps down a card:\n"${comment}"`);
+ } else {
+ lines.push(`**${name}** plays a card silently.`);
+ }
+ }
+
+ return lines.join("\n\n");
+}
+
+// --- Logging helper ---
+
+function logToolCall(toolName: string, args: unknown, result: unknown) {
+ const timestamp = new Date().toISOString();
+ console.log(`\n[${timestamp}] ===== TOOL CALL: ${toolName} =====`);
+ console.log(`[${timestamp}] INPUT:`, JSON.stringify(args, null, 2));
+ console.log(`[${timestamp}] OUTPUT:`, JSON.stringify(result, null, 2));
+ console.log(`[${timestamp}] ===== END: ${toolName} =====\n`);
+}
+
+// --- Server creation ---
+
+// Tool annotations hint to ChatGPT whether to show a confirmation dialog
+// before calling the tool. Setting readOnlyHint: true tells ChatGPT the tool
+// is safe to call without asking the user first.
+const toolAnnotations = {
+ readOnlyHint: true as const,
+ destructiveHint: false as const,
+ openWorldHint: false as const,
+};
+
+// Creates a fresh McpServer per request (stateless pattern). Game state lives
+// in the `gamesById` map, not in the MCP session — so the server doesn't need
+// to track which client is connected.
+function createCardsAgainstAiServer(): McpServer {
+ const server = new McpServer(
+ {
+ name: "cards-against-ai-node",
+ version: "0.1.0",
+ },
+ {
+ capabilities: {
+ resources: {},
+ tools: {},
+ },
+ },
+ );
+
+ // --- Register resources ---
+
+ // The widget HTML is served as an MCP resource so ChatGPT can fetch and
+ // render it in an iframe. CSP metadata tells the iframe which external
+ // domains to allow for network requests and script/image loading.
+ registerAppResource(
+ server,
+ "Cards Against AI widget",
+ TEMPLATE_URI,
+ {
+ description: "Cards Against AI widget markup",
+ mimeType: RESOURCE_MIME_TYPE,
+ },
+ async () => ({
+ contents: [
+ {
+ uri: TEMPLATE_URI,
+ mimeType: RESOURCE_MIME_TYPE,
+ text: widgetHtml,
+ _meta: {
+ ui: {
+ csp: {
+ connectDomains: widgetCspDomains.connectDomains,
+ resourceDomains: widgetCspDomains.resourceDomains,
+ },
+ },
+ },
+ },
+ ],
+ }),
+ );
+
+ // `rules://` resources are context documents. ChatGPT reads these before
+ // the game starts to understand the rules and card creation guidelines.
+ // They aren't displayed in the UI — they inform the model's behavior.
+ registerAppResource(
+ server,
+ "Cards Against AI rules",
+ RULES_URI,
+ {
+ description: "Cards Against AI game rules",
+ mimeType: MARKDOWN_MIME_TYPE,
+ },
+ async () => ({
+ contents: [
+ {
+ uri: RULES_URI,
+ mimeType: MARKDOWN_MIME_TYPE,
+ text: rulesMarkdown,
+ },
+ ],
+ }),
+ );
+
+ registerAppResource(
+ server,
+ "Cards Against AI answer deck guidance",
+ ANSWER_GUIDANCE_URI,
+ {
+ description: "Guidance for crafting the answer deck",
+ mimeType: MARKDOWN_MIME_TYPE,
+ },
+ async () => ({
+ contents: [
+ {
+ uri: ANSWER_GUIDANCE_URI,
+ mimeType: MARKDOWN_MIME_TYPE,
+ text: answerGuidanceMarkdown,
+ },
+ ],
+ }),
+ );
+
+ // --- Register tools ---
+ // Tools registered with `registerAppTool` automatically get MCP Apps
+ // metadata (widget binding, display hints) added to their responses.
+
+ registerAppTool(
+ server,
+ "start-game",
+ {
+ title: "Start a Cards Against AI game",
+ description:
+ "Creates a new game instance and returns its gameId/gameKey along with the initial gameState. Provide exactly 4 players (1 human + 3 CPU recommended). Each player needs: id, name, type ('human' or 'cpu'), answerCards (7 cards each), and persona (required for CPU, optional for human). Persona supports optional fields for richer characters: catchphrase (signature phrase), quirks (behavioral tics), backstory (1-2 sentences), voiceTone ('sarcastic', 'enthusiastic', etc.), and competitiveness (1-10 scale). Populate these richly for CPU players. The firstPrompt is the first round's prompt card text (must contain ____). The introDialog array contains role-played introductions from each CPU character. The response includes gameState and nextAction — use nextAction to determine what tool to call next. First to 5 wins! Full rules are in rules://cards-against-ai. Answer card guidance in rules://cards-against-ai/answer-deck.",
+ inputSchema: startGameShape,
+ _meta: toolUiMeta,
+ annotations: toolAnnotations,
+ },
+ async (args) => {
+ if (!args.firstPrompt.includes("____")) {
+ const result = {
+ _meta: toolUiMeta,
+ isError: true as const,
+ content: [
+ {
+ type: "text" as const,
+ text: "firstPrompt must contain ____ (four underscores) for the blank.",
+ },
+ ],
+ };
+ logToolCall("start-game", args, result);
+ return result;
+ }
+
+ const gameId = randomUUID();
+ const instance = new GameInstance({
+ players: args.players.map((p) => ({
+ id: p.id,
+ name: p.name,
+ type: p.type,
+ persona: p.persona ?? null,
+ answerCards: p.answerCards,
+ })),
+ firstPrompt: args.firstPrompt,
+ });
+ instance.initializeNewGame();
+
+ const gameKey = instance.key;
+ const record = { id: gameId, key: gameKey, instance };
+ gamesById.set(gameId, record);
+
+ const introTextContent = formatIntroDialog(args.introDialog);
+ const result = buildGameToolResponse("start-game", record, introTextContent);
+ logToolCall("start-game", args, result);
+ return result;
+ },
+ );
+
+ registerAppTool(
+ server,
+ "play-answer-card",
+ {
+ title: "Play an answer card",
+ description:
+ "Plays an answer card from the human player's hand. The human will provide gameId, playerId, and cardId via chat. Returns updated gameState and nextAction. CRITICAL: You MUST always check nextAction in the response and call the indicated tool immediately. If nextAction is 'play-cpu-answer-cards', you MUST call play-cpu-answer-cards as your very next tool call or the game will stall.",
+ inputSchema: playAnswerCardShape,
+ _meta: toolUiMeta,
+ annotations: toolAnnotations,
+ },
+ async (args) => {
+ const record = getGameRecord(args.gameId);
+ if (!record) {
+ const result = gameNotFoundError("play-answer-card");
+ logToolCall("play-answer-card", args, result);
+ return result;
+ }
+
+ try {
+ record.instance.playAnswerCard(args.playerId, args.cardId);
+ } catch (error) {
+ const result = {
+ _meta: toolUiMeta,
+ isError: true as const,
+ content: [
+ {
+ type: "text" as const,
+ text: error instanceof Error ? error.message : "Failed to play answer card.",
+ },
+ ],
+ };
+ logToolCall("play-answer-card", args, result);
+ return result;
+ }
+
+ const result = buildGameToolResponse("play-answer-card", record, "");
+ logToolCall("play-answer-card", args, result);
+ return result;
+ },
+ );
+
+ registerAppTool(
+ server,
+ "judge-answer-card",
+ {
+ title: "Judge the winning answer card",
+ description:
+ "Records the human judge's winning card choice. The human will provide gameId, playerId, and winningCardId via chat. Returns updated gameState and nextAction.",
+ inputSchema: judgeAnswerCardShape,
+ _meta: toolUiMeta,
+ annotations: toolAnnotations,
+ },
+ async (args) => {
+ const record = getGameRecord(args.gameId);
+ if (!record) {
+ const result = gameNotFoundError("judge-answer-card");
+ logToolCall("judge-answer-card", args, result);
+ return result;
+ }
+
+ const state = record.instance.getState();
+ const playedCard = state.playedAnswerCards.find(
+ (played) => played.cardId === args.winningCardId,
+ );
+ if (!playedCard) {
+ const result = {
+ _meta: toolUiMeta,
+ isError: true as const,
+ content: [{ type: "text" as const, text: "Winning card not found in played cards." }],
+ };
+ logToolCall("judge-answer-card", args, result);
+ return result;
+ }
+
+ try {
+ record.instance.judgeAnswers({
+ judgeId: args.playerId,
+ winningCardId: args.winningCardId,
+ winningPlayerId: playedCard.playerId,
+ });
+ } catch (error) {
+ const result = {
+ _meta: toolUiMeta,
+ isError: true as const,
+ content: [
+ {
+ type: "text" as const,
+ text: error instanceof Error ? error.message : "Failed to judge answer card.",
+ },
+ ],
+ };
+ logToolCall("judge-answer-card", args, result);
+ return result;
+ }
+
+ const result = buildGameToolResponse("judge-answer-card", record, "");
+ logToolCall("judge-answer-card", args, result);
+ return result;
+ },
+ );
+
+ registerAppTool(
+ server,
+ "play-cpu-answer-cards",
+ {
+ title: "CPU players play answer cards",
+ description:
+ "When nextAction.action === 'play-cpu-answer-cards', use this tool to submit CPU player card selections. Provide cpuAnswerChoices with playerId, cardId, and optional playerComment for each CPU player. Read CPU persona details and card hands from structuredContent.cpuContext in the previous response. CRITICAL: After receiving the response, if the current judge is a CPU, you MUST call cpu-judge-answer-card IMMEDIATELY as your very next tool call. In your response text, write a brief in-character quip from each CPU player as they play their card (1-2 sentences each, using persona details). Occasionally reference or tease the human player by name. Returns updated gameState and nextAction.",
+ inputSchema: playCpuAnswerCardsShape,
+ _meta: toolUiMeta,
+ annotations: toolAnnotations,
+ },
+ async (args) => {
+ const record = getGameRecord(args.gameId);
+ if (!record) {
+ const result = gameNotFoundError("play-cpu-answer-cards");
+ logToolCall("play-cpu-answer-cards", args, result);
+ return result;
+ }
+
+ try {
+ record.instance.submitCpuAnswers(args.cpuAnswerChoices);
+ } catch (error) {
+ const result = {
+ _meta: toolUiMeta,
+ isError: true as const,
+ content: [
+ {
+ type: "text" as const,
+ text: error instanceof Error ? error.message : "Failed to play CPU answer cards.",
+ },
+ ],
+ };
+ logToolCall("play-cpu-answer-cards", args, result);
+ return result;
+ }
+
+ const answerQuips = formatCpuAnswerQuips(args.cpuAnswerChoices, record.instance);
+ const result = buildGameToolResponse("play-cpu-answer-cards", record, answerQuips);
+ logToolCall("play-cpu-answer-cards", args, result);
+ return result;
+ },
+ );
+
+ registerAppTool(
+ server,
+ "cpu-judge-answer-card",
+ {
+ title: "CPU judge picks the winning answer card",
+ description:
+ "When nextAction.action === 'cpu-judge-answer-card', you MUST call this tool immediately — the game will stall if you don't. Submit the CPU judge's verdict with winningCardId and optional reactionToWinningCard. Read the played answer cards from structuredContent.cpuContext in the previous response. In your response text, narrate the judge's dramatic reveal and have 1-2 other players react (groans, celebrations, accusations). Reference the human player sometimes — tease their card, congratulate them, etc. Returns updated gameState and nextAction.",
+ inputSchema: cpuJudgeAnswerCardShape,
+ _meta: toolUiMeta,
+ annotations: toolAnnotations,
+ },
+ async (args) => {
+ const record = getGameRecord(args.gameId);
+ if (!record) {
+ const result = gameNotFoundError("cpu-judge-answer-card");
+ logToolCall("cpu-judge-answer-card", args, result);
+ return result;
+ }
+
+ const stateBefore = record.instance.getState();
+ const judge = stateBefore.players[stateBefore.currentJudgePlayerIndex];
+ const judgeName = judge?.persona?.name ?? "The Judge";
+
+ try {
+ record.instance.submitCpuJudgement({
+ winningCardId: args.winningCardId,
+ reactionToWinningCard: args.reactionToWinningCard,
+ });
+ } catch (error) {
+ const result = {
+ _meta: toolUiMeta,
+ isError: true as const,
+ content: [
+ {
+ type: "text" as const,
+ text: error instanceof Error ? error.message : "Failed to submit CPU judgement.",
+ },
+ ],
+ };
+ logToolCall("cpu-judge-answer-card", args, result);
+ return result;
+ }
+
+ const stateAfter = record.instance.getState();
+ const winningCard = stateAfter.answerCards[args.winningCardId];
+ const winningPlayer = stateAfter.players.find(
+ (p) => p.id === stateAfter.judgementResult?.winningPlayerId,
+ );
+ const winnerName = winningPlayer?.persona?.name ?? "Someone";
+ const cardText = winningCard?.text ?? "???";
+ const reaction = args.reactionToWinningCard?.trim() ?? "This one wins!";
+ const textContent = `**${judgeName}** picks up a card and announces:\n\n"${cardText}"\n\n*${reaction}*\n\n**${winnerName}** wins this round!`;
+
+ const result = buildGameToolResponse("cpu-judge-answer-card", record, textContent);
+ logToolCall("cpu-judge-answer-card", args, result);
+ return result;
+ },
+ );
+
+ registerAppTool(
+ server,
+ "submit-prompt",
+ {
+ title: "Submit a prompt card for the round",
+ description:
+ "When nextAction.action === 'submit-prompt', provide a new prompt card and replacement answer cards. Only callable when the game is in display-judgement or prepare-for-next-round status — calling at other times will return an error with the correct nextAction. The promptText must include exactly one blank (____). The replacementCards array should include one new answer card for each player who played last round (not the judge). In your response text, include brief between-round banter from 1-2 CPU players. Address the human player occasionally. Returns updated gameState and nextAction.",
+ inputSchema: submitPromptShape,
+ _meta: toolUiMeta,
+ annotations: toolAnnotations,
+ },
+ async (args) => {
+ const record = getGameRecord(args.gameId);
+ if (!record) {
+ const result = gameNotFoundError("submit-prompt");
+ logToolCall("submit-prompt", args, result);
+ return result;
+ }
+
+ if (!args.promptText.includes("____")) {
+ const result = {
+ _meta: toolUiMeta,
+ isError: true as const,
+ content: [
+ {
+ type: "text" as const,
+ text: "promptText must contain ____ (four underscores) for the blank.",
+ },
+ ],
+ };
+ logToolCall("submit-prompt", args, result);
+ return result;
+ }
+
+ try {
+ record.instance.submitPrompt(args.promptText, args.replacementCards);
+ } catch (error) {
+ const nextAction = record.instance.computeNextAction();
+ const result = {
+ _meta: toolUiMeta,
+ isError: true as const,
+ content: [
+ {
+ type: "text" as const,
+ text: `${error instanceof Error ? error.message : "Failed to submit prompt."} Current nextAction: ${JSON.stringify(nextAction)}`,
+ },
+ ],
+ };
+ logToolCall("submit-prompt", args, result);
+ return result;
+ }
+
+ const result = buildGameToolResponse("submit-prompt", record, "");
+ logToolCall("submit-prompt", args, result);
+ return result;
+ },
+ );
+
+ return server;
+}
+
+// --- HTTP server using Express + StreamableHTTP ---
+
+const app = express();
+app.use(cors());
+app.use(express.json());
+app.use(express.static(ASSETS_DIR));
+
+// MCP JSON-RPC endpoint. Each request gets a fresh server + transport.
+// `sessionIdGenerator: undefined` disables server-side sessions (stateless).
+// `enableJsonResponse: true` returns JSON instead of SSE streams.
+app.post("/mcp", async (req, res) => {
+ const body = req.body;
+ const method = Array.isArray(body) ? body.map((m: { method?: string }) => m.method).join(", ") : body?.method;
+ console.log(`[mcp] POST /mcp — method: ${method}`);
+
+ const server = createCardsAgainstAiServer();
+ const transport = new StreamableHTTPServerTransport({
+ sessionIdGenerator: undefined,
+ enableJsonResponse: true,
+ });
+ res.on("close", () => {
+ transport.close();
+ });
+ await server.connect(transport);
+ await transport.handleRequest(req, res, req.body);
+});
+
+// Required by the MCP protocol for SSE-based transports. ChatGPT may use GET
+// for server-sent events during the MCP handshake.
+app.get("/mcp", async (req, res) => {
+ const server = createCardsAgainstAiServer();
+ const transport = new StreamableHTTPServerTransport({
+ sessionIdGenerator: undefined,
+ enableJsonResponse: true,
+ });
+ res.on("close", () => {
+ transport.close();
+ });
+ await server.connect(transport);
+ await transport.handleRequest(req, res);
+});
+
+// MCP protocol expects this endpoint. We return 405 because we're stateless
+// (no sessions to delete).
+app.delete("/mcp", async (_req, res) => {
+ res.status(405).end();
+});
+
+// --- Custom SSE endpoint (separate from MCP) ---
+// The widget opens an EventSource here after learning the gameId.
+// GameInstance emits "change" on every state mutation, which pushes the
+// full game state to the widget in real-time. This is NOT part of the MCP
+// protocol — it's a custom endpoint for widget ↔ server real-time sync.
+
+app.get("/mcp/game/:gameId/state-stream", (req, res) => {
+ const record = getGameRecord(req.params.gameId);
+ if (!record) {
+ res.status(404).json({ error: "Game not found" });
+ return;
+ }
+
+ res.writeHead(200, {
+ "Content-Type": "text/event-stream",
+ "Cache-Control": "no-cache",
+ Connection: "keep-alive",
+ "Access-Control-Allow-Origin": "*",
+ });
+
+ const sendState = () => {
+ const nextAction = record.instance.computeNextAction();
+ const cpuContext = nextAction?.notifyModel
+ ? record.instance.getCpuContext()
+ : undefined;
+ const data = JSON.stringify({
+ gameState: record.instance.getState(),
+ nextAction,
+ ...(cpuContext ? { cpuContext } : {}),
+ });
+ res.write(`data: ${data}\n\n`);
+ };
+
+ // Send current state immediately
+ sendState();
+
+ // Push on every change
+ record.instance.on("change", sendState);
+
+ req.on("close", () => {
+ record.instance.removeListener("change", sendState);
+ });
+});
+
+app.listen(port, () => {
+ console.log(
+ `Cards Against AI MCP server listening on http://localhost:${port}`,
+ );
+ console.log(` MCP endpoint: POST http://localhost:${port}/mcp`);
+ console.log(` Static assets: http://localhost:${port}/ (from ${ASSETS_DIR})`);
+ if (BASE_URL) {
+ console.log(` BASE_URL: ${BASE_URL}`);
+ }
+});
diff --git a/cards_against_ai_server_node/src/shared-types.ts b/cards_against_ai_server_node/src/shared-types.ts
new file mode 100644
index 00000000..bbb49d42
--- /dev/null
+++ b/cards_against_ai_server_node/src/shared-types.ts
@@ -0,0 +1,13 @@
+// Types are defined once in the widget code (src/cards-against-ai/types.ts)
+// and re-exported here so the server stays in sync without duplication.
+export type {
+ AnswerCard,
+ ChatMessage,
+ GameState,
+ IntroDialogEntry,
+ JudgementResult,
+ NextActionHint,
+ Persona,
+ Player,
+ PromptCard,
+} from "../../src/cards-against-ai/types.js";
diff --git a/cards_against_ai_server_node/tsconfig.json b/cards_against_ai_server_node/tsconfig.json
new file mode 100644
index 00000000..226aa713
--- /dev/null
+++ b/cards_against_ai_server_node/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "extends": "../tsconfig.json",
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "strict": true,
+ "esModuleInterop": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "types": ["node"]
+ },
+ "include": ["src/**/*.ts", "../src/cards-against-ai/types.ts"]
+}
diff --git a/cards_against_ai_server_node/tsx b/cards_against_ai_server_node/tsx
new file mode 100644
index 00000000..e69de29b
diff --git a/ngrok-v3-stable-linux-amd64.tgz b/ngrok-v3-stable-linux-amd64.tgz
new file mode 100644
index 00000000..3532762f
Binary files /dev/null and b/ngrok-v3-stable-linux-amd64.tgz differ
diff --git a/package.json b/package.json
index 90c2a88f..7ae015c5 100644
--- a/package.json
+++ b/package.json
@@ -32,6 +32,7 @@
"vite": "^7.1.1"
},
"dependencies": {
+ "@modelcontextprotocol/ext-apps": "^1.0.1",
"@openai/apps-sdk-ui": "^0.2.1",
"@react-spring/three": "^10.0.1",
"@react-three/drei": "^10.6.1",
@@ -40,6 +41,7 @@
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"clsx": "^2.1.1",
+ "dotenv": "^17.2.3",
"embla-carousel": "^8.0.0",
"embla-carousel-react": "^8.0.0",
"framer-motion": "^12.23.12",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1852fcbc..a723bf5d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -8,6 +8,9 @@ importers:
.:
dependencies:
+ '@modelcontextprotocol/ext-apps':
+ specifier: ^1.0.1
+ version: 1.0.1(@modelcontextprotocol/sdk@1.26.0(zod@4.1.5))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(zod@4.1.5)
'@openai/apps-sdk-ui':
specifier: ^0.2.1
version: 0.2.1(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(tailwindcss@4.1.11)
@@ -32,6 +35,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
+ dotenv:
+ specifier: ^17.2.3
+ version: 17.2.3
embla-carousel:
specifier: ^8.0.0
version: 8.6.0
@@ -127,6 +133,40 @@ importers:
specifier: ^7.1.1
version: 7.1.1(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.4)
+ cards_against_ai_server_node:
+ dependencies:
+ '@modelcontextprotocol/ext-apps':
+ specifier: ^1.0.1
+ version: 1.0.1(@modelcontextprotocol/sdk@1.26.0(zod@3.25.76))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(zod@3.25.76)
+ '@modelcontextprotocol/sdk':
+ specifier: ^1.26.0
+ version: 1.26.0(zod@3.25.76)
+ cors:
+ specifier: ^2.8.5
+ version: 2.8.6
+ dotenv:
+ specifier: ^17.2.3
+ version: 17.2.3
+ express:
+ specifier: ^5.2.1
+ version: 5.2.1
+ zod:
+ specifier: ^3.25.0
+ version: 3.25.76
+ devDependencies:
+ '@types/cors':
+ specifier: ^2.8.17
+ version: 2.8.19
+ '@types/express':
+ specifier: ^5.0.0
+ version: 5.0.6
+ tsx:
+ specifier: ^4.19.2
+ version: 4.20.4
+ typescript:
+ specifier: ^5.6.3
+ version: 5.9.2
+
kitchen_sink_server_node:
dependencies:
'@modelcontextprotocol/sdk':
@@ -483,6 +523,12 @@ packages:
typescript:
optional: true
+ '@hono/node-server@1.19.9':
+ resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==}
+ engines: {node: '>=18.14.1'}
+ peerDependencies:
+ hono: ^4
+
'@iconify/types@2.0.0':
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
@@ -535,9 +581,32 @@ packages:
'@mermaid-js/parser@0.6.2':
resolution: {integrity: sha512-+PO02uGF6L6Cs0Bw8RpGhikVvMWEysfAyl27qTlroUB8jSWr1lL0Sf6zi78ZxlSnmgSY2AMMKVgghnN9jTtwkQ==}
+ '@modelcontextprotocol/ext-apps@1.0.1':
+ resolution: {integrity: sha512-rAPzBbB5GNgYk216paQjGKUgbNXSy/yeR95c0ni6Y4uvhWI2AeF+ztEOqQFLBMQy/MPM+02pbVK1HaQmQjMwYQ==}
+ peerDependencies:
+ '@modelcontextprotocol/sdk': ^1.24.0
+ react: ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0
+ zod: ^3.25.0 || ^4.0.0
+ peerDependenciesMeta:
+ react:
+ optional: true
+ react-dom:
+ optional: true
+
'@modelcontextprotocol/sdk@0.5.0':
resolution: {integrity: sha512-RXgulUX6ewvxjAG0kOpLMEdXXWkzWgaoCGaA2CwNW7cQCIphjpJhjpHSiaPdVCnisjRF/0Cm9KWHUuIoeiAblQ==}
+ '@modelcontextprotocol/sdk@1.26.0':
+ resolution: {integrity: sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@cfworker/json-schema': ^4.1.1
+ zod: ^3.25 || ^4.0
+ peerDependenciesMeta:
+ '@cfworker/json-schema':
+ optional: true
+
'@monogrid/gainmap-js@3.1.0':
resolution: {integrity: sha512-Obb0/gEd/HReTlg8ttaYk+0m62gQJmCblMOjHSMHRrBP2zdfKMHLCRbh/6ex9fSUJMKdjjIEiohwkbGD3wj2Nw==}
peerDependencies:
@@ -561,6 +630,61 @@ packages:
react: ^18.0.0 || ^19.0.0
tailwindcss: ^4.0.10
+ '@oven/bun-darwin-aarch64@1.3.9':
+ resolution: {integrity: sha512-df7smckMWSUfaT5mzwN9Lfpd3ZGkOqo+vmQ8VV2a32gl14v6uZ/qeeo+1RlANXn8M0uzXPWWCkrKZIWSZUR0qw==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@oven/bun-darwin-x64-baseline@1.3.9':
+ resolution: {integrity: sha512-XbhsA2XAFzvFr0vPSV6SNqGxab4xHKdPmVTLqoSHAx9tffrSq/012BDptOskulwnD+YNsrJUx2D2Ve1xvfgGcg==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@oven/bun-darwin-x64@1.3.9':
+ resolution: {integrity: sha512-YiLxfsPzQqaVvT2a+nxH9do0YfUjrlxF3tKP0b1DDgvfgCcVKGsrQH3Wa82qHgL4dnT8h2bqi94JxXESEuPmcA==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@oven/bun-linux-aarch64-musl@1.3.9':
+ resolution: {integrity: sha512-t8uimCVBTw5f9K2QTZE5wN6UOrFETNrh/Xr7qtXT9nAOzaOnIFvYA+HcHbGfi31fRlCVfTxqm/EiCwJ1gEw9YQ==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@oven/bun-linux-aarch64@1.3.9':
+ resolution: {integrity: sha512-VaNQTu0Up4gnwZLQ6/Hmho6jAlLxTQ1PwxEth8EsXHf82FOXXPV5OCQ6KC9mmmocjKlmWFaIGebThrOy8DUo4g==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@oven/bun-linux-x64-baseline@1.3.9':
+ resolution: {integrity: sha512-nZ12g22cy7pEOBwAxz2tp0wVqekaCn9QRKuGTHqOdLlyAqR4SCdErDvDhUWd51bIyHTQoCmj72TegGTgG0WNPw==}
+ cpu: [x64]
+ os: [linux]
+
+ '@oven/bun-linux-x64-musl-baseline@1.3.9':
+ resolution: {integrity: sha512-3FXQgtYFsT0YOmAdMcJn56pLM5kzSl6y942rJJIl5l2KummB9Ea3J/vMJMzQk7NCAGhleZGWU/pJSS/uXKGa7w==}
+ cpu: [x64]
+ os: [linux]
+
+ '@oven/bun-linux-x64-musl@1.3.9':
+ resolution: {integrity: sha512-4ZjIUgCxEyKwcKXideB5sX0KJpnHTZtu778w73VNq2uNH2fNpMZv98+DBgJyQ9OfFoRhmKn1bmLmSefvnHzI9w==}
+ cpu: [x64]
+ os: [linux]
+
+ '@oven/bun-linux-x64@1.3.9':
+ resolution: {integrity: sha512-oQyAW3+ugulvXTZ+XYeUMmNPR94sJeMokfHQoKwPvVwhVkgRuMhcLGV2ZesHCADVu30Oz2MFXbgdC8x4/o9dRg==}
+ cpu: [x64]
+ os: [linux]
+
+ '@oven/bun-windows-x64-baseline@1.3.9':
+ resolution: {integrity: sha512-a/+hSrrDpMD7THyXvE2KJy1skxzAD0cnW4K1WjuI/91VqsphjNzvf5t/ZgxEVL4wb6f+hKrSJ5J3aH47zPr61g==}
+ cpu: [x64]
+ os: [win32]
+
+ '@oven/bun-windows-x64@1.3.9':
+ resolution: {integrity: sha512-/d6vAmgKvkoYlsGPsRPlPmOK1slPis/F40UG02pYwypTH0wmY0smgzdFqR4YmryxFh17XrW1kITv+U99Oajk9Q==}
+ cpu: [x64]
+ os: [win32]
+
'@radix-ui/number@1.1.1':
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
@@ -1340,11 +1464,21 @@ packages:
cpu: [arm64]
os: [darwin]
+ '@rollup/rollup-darwin-arm64@4.57.1':
+ resolution: {integrity: sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==}
+ cpu: [arm64]
+ os: [darwin]
+
'@rollup/rollup-darwin-x64@4.46.2':
resolution: {integrity: sha512-SSj8TlYV5nJixSsm/y3QXfhspSiLYP11zpfwp6G/YDXctf3Xkdnk4woJIF5VQe0of2OjzTt8EsxnJDCdHd2xMA==}
cpu: [x64]
os: [darwin]
+ '@rollup/rollup-darwin-x64@4.57.1':
+ resolution: {integrity: sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==}
+ cpu: [x64]
+ os: [darwin]
+
'@rollup/rollup-freebsd-arm64@4.46.2':
resolution: {integrity: sha512-ZyrsG4TIT9xnOlLsSSi9w/X29tCbK1yegE49RYm3tu3wF1L/B6LVMqnEWyDB26d9Ecx9zrmXCiPmIabVuLmNSg==}
cpu: [arm64]
@@ -1370,6 +1504,11 @@ packages:
cpu: [arm64]
os: [linux]
+ '@rollup/rollup-linux-arm64-gnu@4.57.1':
+ resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==}
+ cpu: [arm64]
+ os: [linux]
+
'@rollup/rollup-linux-arm64-musl@4.46.2':
resolution: {integrity: sha512-fhHFTutA7SM+IrR6lIfiHskxmpmPTJUXpWIsBXpeEwNgZzZZSg/q4i6FU4J8qOGyJ0TR+wXBwx/L7Ho9z0+uDg==}
cpu: [arm64]
@@ -1405,6 +1544,11 @@ packages:
cpu: [x64]
os: [linux]
+ '@rollup/rollup-linux-x64-gnu@4.57.1':
+ resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==}
+ cpu: [x64]
+ os: [linux]
+
'@rollup/rollup-linux-x64-musl@4.46.2':
resolution: {integrity: sha512-7kX69DIrBeD7yNp4A5b81izs8BqoZkCIaxQaOpumcJ1S/kmqNFjPhDu1LHeVXv0SexfHQv5cqHsxLOjETuqDuA==}
cpu: [x64]
@@ -1415,6 +1559,11 @@ packages:
cpu: [arm64]
os: [win32]
+ '@rollup/rollup-win32-arm64-msvc@4.57.1':
+ resolution: {integrity: sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==}
+ cpu: [arm64]
+ os: [win32]
+
'@rollup/rollup-win32-ia32-msvc@4.46.2':
resolution: {integrity: sha512-gBgaUDESVzMgWZhcyjfs9QFK16D8K6QZpwAaVNJxYDLHWayOta4ZMjGm/vsAEy3hvlS2GosVFlBlP9/Wb85DqQ==}
cpu: [ia32]
@@ -1425,6 +1574,11 @@ packages:
cpu: [x64]
os: [win32]
+ '@rollup/rollup-win32-x64-msvc@4.57.1':
+ resolution: {integrity: sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==}
+ cpu: [x64]
+ os: [win32]
+
'@tailwindcss/node@4.1.11':
resolution: {integrity: sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q==}
@@ -1530,6 +1684,15 @@ packages:
'@types/babel__traverse@7.28.0':
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
+ '@types/body-parser@1.19.6':
+ resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
+
+ '@types/connect@3.4.38':
+ resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
+
+ '@types/cors@2.8.19':
+ resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==}
+
'@types/d3-array@3.2.1':
resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==}
@@ -1635,6 +1798,12 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
+ '@types/express-serve-static-core@5.1.1':
+ resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==}
+
+ '@types/express@5.0.6':
+ resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==}
+
'@types/geojson-vt@3.2.5':
resolution: {integrity: sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==}
@@ -1649,6 +1818,9 @@ packages:
peerDependencies:
'@types/react': '*'
+ '@types/http-errors@2.0.5':
+ resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
+
'@types/katex@0.16.7':
resolution: {integrity: sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==}
@@ -1676,6 +1848,12 @@ packages:
'@types/prismjs@1.26.5':
resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==}
+ '@types/qs@6.14.0':
+ resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==}
+
+ '@types/range-parser@1.2.7':
+ resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
+
'@types/react-dom@19.1.9':
resolution: {integrity: sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==}
peerDependencies:
@@ -1694,6 +1872,12 @@ packages:
'@types/react@19.1.12':
resolution: {integrity: sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==}
+ '@types/send@1.2.1':
+ resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==}
+
+ '@types/serve-static@2.2.0':
+ resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==}
+
'@types/stats.js@0.17.4':
resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==}
@@ -1742,14 +1926,29 @@ packages:
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
engines: {node: '>= 0.6'}
+ accepts@2.0.0:
+ resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
+ engines: {node: '>= 0.6'}
+
acorn@8.15.0:
resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==}
engines: {node: '>=0.4.0'}
hasBin: true
+ ajv-formats@3.0.1:
+ resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
+ peerDependencies:
+ ajv: ^8.0.0
+ peerDependenciesMeta:
+ ajv:
+ optional: true
+
ajv@8.12.0:
resolution: {integrity: sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==}
+ ajv@8.17.1:
+ resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==}
+
ansi-align@3.0.1:
resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==}
@@ -1798,6 +1997,10 @@ packages:
bidi-js@1.0.3:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
+ body-parser@2.2.2:
+ resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
+ engines: {node: '>=18'}
+
boxen@7.0.0:
resolution: {integrity: sha512-j//dBVuyacJbvW+tvZ9HuH03fZ46QcaKvvhZickZqtB271DxJ7SNRSNxrV/dZX0085m7hISRZWbzWlJvx/rHSg==}
engines: {node: '>=14.16'}
@@ -1825,6 +2028,14 @@ packages:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'}
+ call-bind-apply-helpers@1.0.2:
+ resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
+ engines: {node: '>= 0.4'}
+
+ call-bound@1.0.4:
+ resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
+ engines: {node: '>= 0.4'}
+
camelcase@7.0.1:
resolution: {integrity: sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==}
engines: {node: '>=14.16'}
@@ -1931,6 +2142,10 @@ packages:
resolution: {integrity: sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==}
engines: {node: '>= 0.6'}
+ content-disposition@1.0.1:
+ resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==}
+ engines: {node: '>=18'}
+
content-type@1.0.5:
resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
engines: {node: '>= 0.6'}
@@ -1938,10 +2153,22 @@ packages:
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+ cookie-signature@1.2.2:
+ resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
+ engines: {node: '>=6.6.0'}
+
+ cookie@0.7.2:
+ resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
+ engines: {node: '>= 0.6'}
+
cookie@1.0.2:
resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==}
engines: {node: '>=18'}
+ cors@2.8.6:
+ resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
+ engines: {node: '>= 0.10'}
+
cose-base@1.0.3:
resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==}
@@ -2142,6 +2369,15 @@ packages:
supports-color:
optional: true
+ debug@4.4.3:
+ resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
+ engines: {node: '>=6.0'}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
@@ -2179,15 +2415,26 @@ packages:
dompurify@3.2.6:
resolution: {integrity: sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==}
+ dotenv@17.2.3:
+ resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==}
+ engines: {node: '>=12'}
+
draco3d@1.5.7:
resolution: {integrity: sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==}
+ dunder-proto@1.0.1:
+ resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
+ engines: {node: '>= 0.4'}
+
earcut@3.0.2:
resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==}
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
+ ee-first@1.1.1:
+ resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
+
electron-to-chromium@1.5.195:
resolution: {integrity: sha512-URclP0iIaDUzqcAyV1v2PgduJ9N0IdXmWsnPzPfelvBmjmZzEy6xJcjb1cXj+TbYqXgtLrjHEoaSIdTYhw4ezg==}
@@ -2210,6 +2457,10 @@ packages:
emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
+ encodeurl@2.0.0:
+ resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
+ engines: {node: '>= 0.8'}
+
enhanced-resolve@5.18.2:
resolution: {integrity: sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==}
engines: {node: '>=10.13.0'}
@@ -2218,6 +2469,18 @@ packages:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
engines: {node: '>=0.12'}
+ es-define-property@1.0.1:
+ resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
+ engines: {node: '>= 0.4'}
+
+ es-errors@1.3.0:
+ resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
+ engines: {node: '>= 0.4'}
+
+ es-object-atoms@1.1.1:
+ resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
+ engines: {node: '>= 0.4'}
+
esbuild@0.25.8:
resolution: {integrity: sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==}
engines: {node: '>=18'}
@@ -2227,6 +2490,9 @@ packages:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
+ escape-html@1.0.3:
+ resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
+
escape-string-regexp@5.0.0:
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
engines: {node: '>=12'}
@@ -2234,10 +2500,32 @@ packages:
estree-util-is-identifier-name@3.0.0:
resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==}
+ etag@1.8.1:
+ resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
+ engines: {node: '>= 0.6'}
+
+ eventsource-parser@3.0.6:
+ resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==}
+ engines: {node: '>=18.0.0'}
+
+ eventsource@3.0.7:
+ resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==}
+ engines: {node: '>=18.0.0'}
+
execa@5.1.1:
resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
engines: {node: '>=10'}
+ express-rate-limit@8.2.1:
+ resolution: {integrity: sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==}
+ engines: {node: '>= 16'}
+ peerDependencies:
+ express: '>= 4.11'
+
+ express@5.2.1:
+ resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
+ engines: {node: '>= 18'}
+
exsolve@1.0.7:
resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==}
@@ -2251,6 +2539,9 @@ packages:
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
engines: {node: '>=8.6.0'}
+ fast-uri@3.1.0:
+ resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
+
fastq@1.19.1:
resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==}
@@ -2275,10 +2566,18 @@ packages:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
+ finalhandler@2.1.1:
+ resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
+ engines: {node: '>= 18.0.0'}
+
format@0.2.2:
resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==}
engines: {node: '>=0.4.x'}
+ forwarded@0.2.0:
+ resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
+ engines: {node: '>= 0.6'}
+
fraction.js@4.3.7:
resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
@@ -2296,11 +2595,18 @@ packages:
react-dom:
optional: true
+ fresh@2.0.0:
+ resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
+ engines: {node: '>= 0.8'}
+
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
+ function-bind@1.1.2:
+ resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
+
gensync@1.0.0-beta.2:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'}
@@ -2308,10 +2614,18 @@ packages:
geojson-vt@4.0.2:
resolution: {integrity: sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==}
+ get-intrinsic@1.3.0:
+ resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
+ engines: {node: '>= 0.4'}
+
get-nonce@1.0.1:
resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
engines: {node: '>=6'}
+ get-proto@1.0.1:
+ resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
+ engines: {node: '>= 0.4'}
+
get-stream@6.0.1:
resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==}
engines: {node: '>=10'}
@@ -2333,6 +2647,10 @@ packages:
glsl-noise@0.0.0:
resolution: {integrity: sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==}
+ gopd@1.2.0:
+ resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
+ engines: {node: '>= 0.4'}
+
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
@@ -2346,6 +2664,14 @@ packages:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
+ has-symbols@1.1.0:
+ resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
+ engines: {node: '>= 0.4'}
+
+ hasown@2.0.2:
+ resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
+ engines: {node: '>= 0.4'}
+
hast-util-from-dom@5.0.1:
resolution: {integrity: sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==}
@@ -2388,6 +2714,10 @@ packages:
hoist-non-react-statics@3.3.2:
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
+ hono@4.11.9:
+ resolution: {integrity: sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==}
+ engines: {node: '>=16.9.0'}
+
html-url-attributes@3.0.1:
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
@@ -2395,6 +2725,10 @@ packages:
resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
engines: {node: '>= 0.8'}
+ http-errors@2.0.1:
+ resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
+ engines: {node: '>= 0.8'}
+
human-signals@2.1.0:
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
engines: {node: '>=10.17.0'}
@@ -2435,6 +2769,14 @@ packages:
intl-messageformat@10.7.16:
resolution: {integrity: sha512-UmdmHUmp5CIKKjSoE10la5yfU+AYJAaiYLsodbjL4lji83JNvgOQUjGaGhGrpFCb0Uh7sl7qfP1IyILa8Z40ug==}
+ ip-address@10.0.1:
+ resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==}
+ engines: {node: '>= 12'}
+
+ ipaddr.js@1.9.1:
+ resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
+ engines: {node: '>= 0.10'}
+
is-alphabetical@2.0.1:
resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==}
@@ -2479,6 +2821,9 @@ packages:
is-promise@2.2.2:
resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==}
+ is-promise@4.0.0:
+ resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
+
is-stream@2.0.1:
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
engines: {node: '>=8'}
@@ -2499,6 +2844,9 @@ packages:
resolution: {integrity: sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==}
hasBin: true
+ jose@6.1.3:
+ resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==}
+
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -2510,6 +2858,9 @@ packages:
json-schema-traverse@1.0.0:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
+ json-schema-typed@8.0.2:
+ resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==}
+
json5@2.2.3:
resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
engines: {node: '>=6'}
@@ -2665,6 +3016,10 @@ packages:
martinez-polygon-clipping@0.7.4:
resolution: {integrity: sha512-jBEwrKtA0jTagUZj2bnmb4Yg2s4KnJGRePStgI7bAVjtcipKiF39R4LZ2V/UT61jMYWrTcBhPazexeqd6JAVtw==}
+ math-intrinsics@1.1.0:
+ resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
+ engines: {node: '>= 0.4'}
+
mdast-util-directive@3.1.0:
resolution: {integrity: sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q==}
@@ -2719,6 +3074,14 @@ packages:
mdast-util-to-string@4.0.0:
resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==}
+ media-typer@1.1.0:
+ resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
+ engines: {node: '>= 0.8'}
+
+ merge-descriptors@2.0.0:
+ resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==}
+ engines: {node: '>=18'}
+
merge-stream@2.0.0:
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
@@ -2851,6 +3214,10 @@ packages:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
+ mime-types@3.0.2:
+ resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
+ engines: {node: '>=18'}
+
mimic-fn@2.1.0:
resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
engines: {node: '>=6'}
@@ -2907,6 +3274,10 @@ packages:
resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
engines: {node: '>= 0.6'}
+ negotiator@1.0.0:
+ resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
+ engines: {node: '>= 0.6'}
+
node-releases@2.0.19:
resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==}
@@ -2918,10 +3289,25 @@ packages:
resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
engines: {node: '>=8'}
+ object-assign@4.1.1:
+ resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
+ engines: {node: '>=0.10.0'}
+
+ object-inspect@1.13.4:
+ resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
+ engines: {node: '>= 0.4'}
+
+ on-finished@2.4.1:
+ resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
+ engines: {node: '>= 0.8'}
+
on-headers@1.0.2:
resolution: {integrity: sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==}
engines: {node: '>= 0.8'}
+ once@1.4.0:
+ resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
+
onetime@5.1.2:
resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
engines: {node: '>=6'}
@@ -2935,6 +3321,10 @@ packages:
parse5@7.3.0:
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
+ parseurl@1.3.3:
+ resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
+ engines: {node: '>= 0.8'}
+
partial-json@0.1.7:
resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==}
@@ -2951,6 +3341,9 @@ packages:
path-to-regexp@3.3.0:
resolution: {integrity: sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==}
+ path-to-regexp@8.3.0:
+ resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==}
+
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
@@ -2969,6 +3362,10 @@ packages:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
+ pkce-challenge@5.0.1:
+ resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
+ engines: {node: '>=16.20.0'}
+
pkg-types@1.3.1:
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
@@ -3012,10 +3409,18 @@ packages:
protocol-buffers-schema@3.6.0:
resolution: {integrity: sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==}
+ proxy-addr@2.0.7:
+ resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
+ engines: {node: '>= 0.10'}
+
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
+ qs@6.14.1:
+ resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==}
+ engines: {node: '>=0.6'}
+
quansync@0.2.10:
resolution: {integrity: sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A==}
@@ -3042,6 +3447,10 @@ packages:
resolution: {integrity: sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==}
engines: {node: '>= 0.6'}
+ range-parser@1.2.1:
+ resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
+ engines: {node: '>= 0.6'}
+
raw-body@3.0.1:
resolution: {integrity: sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==}
engines: {node: '>= 0.10'}
@@ -3220,6 +3629,10 @@ packages:
roughjs@4.6.6:
resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==}
+ router@2.2.0:
+ resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
+ engines: {node: '>= 18'}
+
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
@@ -3245,6 +3658,10 @@ packages:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
+ send@1.2.1:
+ resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
+ engines: {node: '>= 18'}
+
serialize-to-js@3.1.2:
resolution: {integrity: sha512-owllqNuDDEimQat7EPG0tH7JjO090xKNzUtYz6X+Sk2BXDnOCilDdNLwjWeFywG9xkJul1ULvtUQa9O4pUaY0w==}
engines: {node: '>=4.0.0'}
@@ -3252,6 +3669,10 @@ packages:
serve-handler@6.1.6:
resolution: {integrity: sha512-x5RL9Y2p5+Sh3D38Fh9i/iQ5ZK+e4xuXRd/pGbM4D13tgo/MGwbttUk8emytcr1YYzBYs+apnUngBDFYfpjPuQ==}
+ serve-static@2.2.1:
+ resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
+ engines: {node: '>= 18'}
+
serve@14.2.4:
resolution: {integrity: sha512-qy1S34PJ/fcY8gjVGszDB3EXiPSk5FKhUa7tQe0UPRddxRidc2V6cNHPNewbE1D7MAkgLuWEt3Vw56vYy73tzQ==}
engines: {node: '>= 14'}
@@ -3271,6 +3692,22 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
+ side-channel-list@1.0.0:
+ resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
+ engines: {node: '>= 0.4'}
+
+ side-channel-map@1.0.1:
+ resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
+ engines: {node: '>= 0.4'}
+
+ side-channel-weakmap@1.0.2:
+ resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
+ engines: {node: '>= 0.4'}
+
+ side-channel@1.1.0:
+ resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
+ engines: {node: '>= 0.4'}
+
signal-exit@3.0.7:
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
@@ -3297,6 +3734,10 @@ packages:
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
engines: {node: '>= 0.8'}
+ statuses@2.0.2:
+ resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
+ engines: {node: '>= 0.8'}
+
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
@@ -3358,6 +3799,7 @@ packages:
tar@7.4.3:
resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==}
engines: {node: '>=18'}
+ deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me
three-mesh-bvh@0.8.3:
resolution: {integrity: sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg==}
@@ -3431,6 +3873,10 @@ packages:
resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==}
engines: {node: '>=12.20'}
+ type-is@2.0.1:
+ resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==}
+ engines: {node: '>= 0.6'}
+
typescript@5.9.2:
resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==}
engines: {node: '>=14.17'}
@@ -3616,6 +4062,9 @@ packages:
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
engines: {node: '>=12'}
+ wrappy@1.0.2:
+ resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
+
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@@ -3623,6 +4072,11 @@ packages:
resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==}
engines: {node: '>=18'}
+ zod-to-json-schema@3.25.1:
+ resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==}
+ peerDependencies:
+ zod: ^3.25 || ^4
+
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
@@ -3955,6 +4409,10 @@ snapshots:
optionalDependencies:
typescript: 5.9.2
+ '@hono/node-server@1.19.9(hono@4.11.9)':
+ dependencies:
+ hono: 4.11.9
+
'@iconify/types@2.0.0': {}
'@iconify/utils@2.3.0':
@@ -4012,12 +4470,106 @@ snapshots:
dependencies:
langium: 3.3.1
+ '@modelcontextprotocol/ext-apps@1.0.1(@modelcontextprotocol/sdk@1.26.0(zod@3.25.76))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(zod@3.25.76)':
+ dependencies:
+ '@modelcontextprotocol/sdk': 1.26.0(zod@3.25.76)
+ zod: 3.25.76
+ optionalDependencies:
+ '@oven/bun-darwin-aarch64': 1.3.9
+ '@oven/bun-darwin-x64': 1.3.9
+ '@oven/bun-darwin-x64-baseline': 1.3.9
+ '@oven/bun-linux-aarch64': 1.3.9
+ '@oven/bun-linux-aarch64-musl': 1.3.9
+ '@oven/bun-linux-x64': 1.3.9
+ '@oven/bun-linux-x64-baseline': 1.3.9
+ '@oven/bun-linux-x64-musl': 1.3.9
+ '@oven/bun-linux-x64-musl-baseline': 1.3.9
+ '@oven/bun-windows-x64': 1.3.9
+ '@oven/bun-windows-x64-baseline': 1.3.9
+ '@rollup/rollup-darwin-arm64': 4.57.1
+ '@rollup/rollup-darwin-x64': 4.57.1
+ '@rollup/rollup-linux-arm64-gnu': 4.57.1
+ '@rollup/rollup-linux-x64-gnu': 4.57.1
+ '@rollup/rollup-win32-arm64-msvc': 4.57.1
+ '@rollup/rollup-win32-x64-msvc': 4.57.1
+ react: 19.1.1
+ react-dom: 19.1.1(react@19.1.1)
+
+ '@modelcontextprotocol/ext-apps@1.0.1(@modelcontextprotocol/sdk@1.26.0(zod@4.1.5))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(zod@4.1.5)':
+ dependencies:
+ '@modelcontextprotocol/sdk': 1.26.0(zod@4.1.5)
+ zod: 4.1.5
+ optionalDependencies:
+ '@oven/bun-darwin-aarch64': 1.3.9
+ '@oven/bun-darwin-x64': 1.3.9
+ '@oven/bun-darwin-x64-baseline': 1.3.9
+ '@oven/bun-linux-aarch64': 1.3.9
+ '@oven/bun-linux-aarch64-musl': 1.3.9
+ '@oven/bun-linux-x64': 1.3.9
+ '@oven/bun-linux-x64-baseline': 1.3.9
+ '@oven/bun-linux-x64-musl': 1.3.9
+ '@oven/bun-linux-x64-musl-baseline': 1.3.9
+ '@oven/bun-windows-x64': 1.3.9
+ '@oven/bun-windows-x64-baseline': 1.3.9
+ '@rollup/rollup-darwin-arm64': 4.57.1
+ '@rollup/rollup-darwin-x64': 4.57.1
+ '@rollup/rollup-linux-arm64-gnu': 4.57.1
+ '@rollup/rollup-linux-x64-gnu': 4.57.1
+ '@rollup/rollup-win32-arm64-msvc': 4.57.1
+ '@rollup/rollup-win32-x64-msvc': 4.57.1
+ react: 19.1.1
+ react-dom: 19.1.1(react@19.1.1)
+
'@modelcontextprotocol/sdk@0.5.0':
dependencies:
content-type: 1.0.5
raw-body: 3.0.1
zod: 3.25.76
+ '@modelcontextprotocol/sdk@1.26.0(zod@3.25.76)':
+ dependencies:
+ '@hono/node-server': 1.19.9(hono@4.11.9)
+ ajv: 8.17.1
+ ajv-formats: 3.0.1(ajv@8.17.1)
+ content-type: 1.0.5
+ cors: 2.8.6
+ cross-spawn: 7.0.6
+ eventsource: 3.0.7
+ eventsource-parser: 3.0.6
+ express: 5.2.1
+ express-rate-limit: 8.2.1(express@5.2.1)
+ hono: 4.11.9
+ jose: 6.1.3
+ json-schema-typed: 8.0.2
+ pkce-challenge: 5.0.1
+ raw-body: 3.0.1
+ zod: 3.25.76
+ zod-to-json-schema: 3.25.1(zod@3.25.76)
+ transitivePeerDependencies:
+ - supports-color
+
+ '@modelcontextprotocol/sdk@1.26.0(zod@4.1.5)':
+ dependencies:
+ '@hono/node-server': 1.19.9(hono@4.11.9)
+ ajv: 8.17.1
+ ajv-formats: 3.0.1(ajv@8.17.1)
+ content-type: 1.0.5
+ cors: 2.8.6
+ cross-spawn: 7.0.6
+ eventsource: 3.0.7
+ eventsource-parser: 3.0.6
+ express: 5.2.1
+ express-rate-limit: 8.2.1(express@5.2.1)
+ hono: 4.11.9
+ jose: 6.1.3
+ json-schema-typed: 8.0.2
+ pkce-challenge: 5.0.1
+ raw-body: 3.0.1
+ zod: 4.1.5
+ zod-to-json-schema: 3.25.1(zod@4.1.5)
+ transitivePeerDependencies:
+ - supports-color
+
'@monogrid/gainmap-js@3.1.0(three@0.179.1)':
dependencies:
promise-worker-transferable: 1.0.4
@@ -4059,6 +4611,39 @@ snapshots:
- react-dom
- supports-color
+ '@oven/bun-darwin-aarch64@1.3.9':
+ optional: true
+
+ '@oven/bun-darwin-x64-baseline@1.3.9':
+ optional: true
+
+ '@oven/bun-darwin-x64@1.3.9':
+ optional: true
+
+ '@oven/bun-linux-aarch64-musl@1.3.9':
+ optional: true
+
+ '@oven/bun-linux-aarch64@1.3.9':
+ optional: true
+
+ '@oven/bun-linux-x64-baseline@1.3.9':
+ optional: true
+
+ '@oven/bun-linux-x64-musl-baseline@1.3.9':
+ optional: true
+
+ '@oven/bun-linux-x64-musl@1.3.9':
+ optional: true
+
+ '@oven/bun-linux-x64@1.3.9':
+ optional: true
+
+ '@oven/bun-windows-x64-baseline@1.3.9':
+ optional: true
+
+ '@oven/bun-windows-x64@1.3.9':
+ optional: true
+
'@radix-ui/number@1.1.1': {}
'@radix-ui/primitive@1.1.3': {}
@@ -4916,9 +5501,15 @@ snapshots:
'@rollup/rollup-darwin-arm64@4.46.2':
optional: true
+ '@rollup/rollup-darwin-arm64@4.57.1':
+ optional: true
+
'@rollup/rollup-darwin-x64@4.46.2':
optional: true
+ '@rollup/rollup-darwin-x64@4.57.1':
+ optional: true
+
'@rollup/rollup-freebsd-arm64@4.46.2':
optional: true
@@ -4934,6 +5525,9 @@ snapshots:
'@rollup/rollup-linux-arm64-gnu@4.46.2':
optional: true
+ '@rollup/rollup-linux-arm64-gnu@4.57.1':
+ optional: true
+
'@rollup/rollup-linux-arm64-musl@4.46.2':
optional: true
@@ -4955,18 +5549,27 @@ snapshots:
'@rollup/rollup-linux-x64-gnu@4.46.2':
optional: true
+ '@rollup/rollup-linux-x64-gnu@4.57.1':
+ optional: true
+
'@rollup/rollup-linux-x64-musl@4.46.2':
optional: true
'@rollup/rollup-win32-arm64-msvc@4.46.2':
optional: true
+ '@rollup/rollup-win32-arm64-msvc@4.57.1':
+ optional: true
+
'@rollup/rollup-win32-ia32-msvc@4.46.2':
optional: true
'@rollup/rollup-win32-x64-msvc@4.46.2':
optional: true
+ '@rollup/rollup-win32-x64-msvc@4.57.1':
+ optional: true
+
'@tailwindcss/node@4.1.11':
dependencies:
'@ampproject/remapping': 2.3.0
@@ -5061,6 +5664,19 @@ snapshots:
dependencies:
'@babel/types': 7.28.2
+ '@types/body-parser@1.19.6':
+ dependencies:
+ '@types/connect': 3.4.38
+ '@types/node': 24.3.0
+
+ '@types/connect@3.4.38':
+ dependencies:
+ '@types/node': 24.3.0
+
+ '@types/cors@2.8.19':
+ dependencies:
+ '@types/node': 24.3.0
+
'@types/d3-array@3.2.1': {}
'@types/d3-axis@3.0.6':
@@ -5190,6 +5806,19 @@ snapshots:
'@types/estree@1.0.8': {}
+ '@types/express-serve-static-core@5.1.1':
+ dependencies:
+ '@types/node': 24.3.0
+ '@types/qs': 6.14.0
+ '@types/range-parser': 1.2.7
+ '@types/send': 1.2.1
+
+ '@types/express@5.0.6':
+ dependencies:
+ '@types/body-parser': 1.19.6
+ '@types/express-serve-static-core': 5.1.1
+ '@types/serve-static': 2.2.0
+
'@types/geojson-vt@3.2.5':
dependencies:
'@types/geojson': 7946.0.16
@@ -5205,6 +5834,8 @@ snapshots:
'@types/react': 19.1.12
hoist-non-react-statics: 3.3.2
+ '@types/http-errors@2.0.5': {}
+
'@types/katex@0.16.7': {}
'@types/lodash@4.17.20': {}
@@ -5227,6 +5858,10 @@ snapshots:
'@types/prismjs@1.26.5': {}
+ '@types/qs@6.14.0': {}
+
+ '@types/range-parser@1.2.7': {}
+
'@types/react-dom@19.1.9(@types/react@19.1.12)':
dependencies:
'@types/react': 19.1.12
@@ -5243,6 +5878,15 @@ snapshots:
dependencies:
csstype: 3.1.3
+ '@types/send@1.2.1':
+ dependencies:
+ '@types/node': 24.3.0
+
+ '@types/serve-static@2.2.0':
+ dependencies:
+ '@types/http-errors': 2.0.5
+ '@types/node': 24.3.0
+
'@types/stats.js@0.17.4': {}
'@types/supercluster@7.1.3':
@@ -5298,8 +5942,17 @@ snapshots:
mime-types: 2.1.35
negotiator: 0.6.3
+ accepts@2.0.0:
+ dependencies:
+ mime-types: 3.0.2
+ negotiator: 1.0.0
+
acorn@8.15.0: {}
+ ajv-formats@3.0.1(ajv@8.17.1):
+ optionalDependencies:
+ ajv: 8.17.1
+
ajv@8.12.0:
dependencies:
fast-deep-equal: 3.1.3
@@ -5307,6 +5960,13 @@ snapshots:
require-from-string: 2.0.2
uri-js: 4.4.1
+ ajv@8.17.1:
+ dependencies:
+ fast-deep-equal: 3.1.3
+ fast-uri: 3.1.0
+ json-schema-traverse: 1.0.0
+ require-from-string: 2.0.2
+
ansi-align@3.0.1:
dependencies:
string-width: 4.2.3
@@ -5349,6 +6009,20 @@ snapshots:
dependencies:
require-from-string: 2.0.2
+ body-parser@2.2.2:
+ dependencies:
+ bytes: 3.1.2
+ content-type: 1.0.5
+ debug: 4.4.3
+ http-errors: 2.0.1
+ iconv-lite: 0.7.0
+ on-finished: 2.4.1
+ qs: 6.14.1
+ raw-body: 3.0.1
+ type-is: 2.0.1
+ transitivePeerDependencies:
+ - supports-color
+
boxen@7.0.0:
dependencies:
ansi-align: 3.0.1
@@ -5385,6 +6059,16 @@ snapshots:
bytes@3.1.2: {}
+ call-bind-apply-helpers@1.0.2:
+ dependencies:
+ es-errors: 1.3.0
+ function-bind: 1.1.2
+
+ call-bound@1.0.4:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ get-intrinsic: 1.3.0
+
camelcase@7.0.1: {}
camera-controls@3.1.0(three@0.179.1):
@@ -5478,12 +6162,23 @@ snapshots:
content-disposition@0.5.2: {}
+ content-disposition@1.0.1: {}
+
content-type@1.0.5: {}
convert-source-map@2.0.0: {}
+ cookie-signature@1.2.2: {}
+
+ cookie@0.7.2: {}
+
cookie@1.0.2: {}
+ cors@2.8.6:
+ dependencies:
+ object-assign: 4.1.1
+ vary: 1.1.2
+
cose-base@1.0.3:
dependencies:
layout-base: 1.0.2
@@ -5702,6 +6397,10 @@ snapshots:
dependencies:
ms: 2.1.3
+ debug@4.4.3:
+ dependencies:
+ ms: 2.1.3
+
decimal.js@10.6.0: {}
decode-named-character-reference@1.2.0:
@@ -5734,12 +6433,22 @@ snapshots:
optionalDependencies:
'@types/trusted-types': 2.0.7
+ dotenv@17.2.3: {}
+
draco3d@1.5.7: {}
+ dunder-proto@1.0.1:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ es-errors: 1.3.0
+ gopd: 1.2.0
+
earcut@3.0.2: {}
eastasianwidth@0.2.0: {}
+ ee-first@1.1.1: {}
+
electron-to-chromium@1.5.195: {}
embla-carousel-react@8.6.0(react@19.1.1):
@@ -5758,6 +6467,8 @@ snapshots:
emoji-regex@9.2.2: {}
+ encodeurl@2.0.0: {}
+
enhanced-resolve@5.18.2:
dependencies:
graceful-fs: 4.2.11
@@ -5765,6 +6476,14 @@ snapshots:
entities@6.0.1: {}
+ es-define-property@1.0.1: {}
+
+ es-errors@1.3.0: {}
+
+ es-object-atoms@1.1.1:
+ dependencies:
+ es-errors: 1.3.0
+
esbuild@0.25.8:
optionalDependencies:
'@esbuild/aix-ppc64': 0.25.8
@@ -5796,10 +6515,20 @@ snapshots:
escalade@3.2.0: {}
+ escape-html@1.0.3: {}
+
escape-string-regexp@5.0.0: {}
estree-util-is-identifier-name@3.0.0: {}
+ etag@1.8.1: {}
+
+ eventsource-parser@3.0.6: {}
+
+ eventsource@3.0.7:
+ dependencies:
+ eventsource-parser: 3.0.6
+
execa@5.1.1:
dependencies:
cross-spawn: 7.0.6
@@ -5812,6 +6541,44 @@ snapshots:
signal-exit: 3.0.7
strip-final-newline: 2.0.0
+ express-rate-limit@8.2.1(express@5.2.1):
+ dependencies:
+ express: 5.2.1
+ ip-address: 10.0.1
+
+ express@5.2.1:
+ dependencies:
+ accepts: 2.0.0
+ body-parser: 2.2.2
+ content-disposition: 1.0.1
+ content-type: 1.0.5
+ cookie: 0.7.2
+ cookie-signature: 1.2.2
+ debug: 4.4.3
+ depd: 2.0.0
+ encodeurl: 2.0.0
+ escape-html: 1.0.3
+ etag: 1.8.1
+ finalhandler: 2.1.1
+ fresh: 2.0.0
+ http-errors: 2.0.1
+ merge-descriptors: 2.0.0
+ mime-types: 3.0.2
+ on-finished: 2.4.1
+ once: 1.4.0
+ parseurl: 1.3.3
+ proxy-addr: 2.0.7
+ qs: 6.14.1
+ range-parser: 1.2.1
+ router: 2.2.0
+ send: 1.2.1
+ serve-static: 2.2.1
+ statuses: 2.0.2
+ type-is: 2.0.1
+ vary: 1.1.2
+ transitivePeerDependencies:
+ - supports-color
+
exsolve@1.0.7: {}
extend@3.0.2: {}
@@ -5826,6 +6593,8 @@ snapshots:
merge2: 1.4.1
micromatch: 4.0.8
+ fast-uri@3.1.0: {}
+
fastq@1.19.1:
dependencies:
reusify: 1.1.0
@@ -5846,8 +6615,21 @@ snapshots:
dependencies:
to-regex-range: 5.0.1
+ finalhandler@2.1.1:
+ dependencies:
+ debug: 4.4.3
+ encodeurl: 2.0.0
+ escape-html: 1.0.3
+ on-finished: 2.4.1
+ parseurl: 1.3.3
+ statuses: 2.0.2
+ transitivePeerDependencies:
+ - supports-color
+
format@0.2.2: {}
+ forwarded@0.2.0: {}
+
fraction.js@4.3.7: {}
framer-motion@12.23.12(react-dom@19.1.1(react@19.1.1))(react@19.1.1):
@@ -5859,15 +6641,37 @@ snapshots:
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
+ fresh@2.0.0: {}
+
fsevents@2.3.3:
optional: true
+ function-bind@1.1.2: {}
+
gensync@1.0.0-beta.2: {}
geojson-vt@4.0.2: {}
+ get-intrinsic@1.3.0:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ es-define-property: 1.0.1
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.1
+ function-bind: 1.1.2
+ get-proto: 1.0.1
+ gopd: 1.2.0
+ has-symbols: 1.1.0
+ hasown: 2.0.2
+ math-intrinsics: 1.1.0
+
get-nonce@1.0.1: {}
+ get-proto@1.0.1:
+ dependencies:
+ dunder-proto: 1.0.1
+ es-object-atoms: 1.1.1
+
get-stream@6.0.1: {}
get-tsconfig@4.10.1:
@@ -5884,6 +6688,8 @@ snapshots:
glsl-noise@0.0.0: {}
+ gopd@1.2.0: {}
+
graceful-fs@4.2.11: {}
grid-index@1.1.0: {}
@@ -5892,6 +6698,12 @@ snapshots:
has-flag@4.0.0: {}
+ has-symbols@1.1.0: {}
+
+ hasown@2.0.2:
+ dependencies:
+ function-bind: 1.1.2
+
hast-util-from-dom@5.0.1:
dependencies:
'@types/hast': 3.0.4
@@ -5982,6 +6794,8 @@ snapshots:
dependencies:
react-is: 16.13.1
+ hono@4.11.9: {}
+
html-url-attributes@3.0.1: {}
http-errors@2.0.0:
@@ -5992,6 +6806,14 @@ snapshots:
statuses: 2.0.1
toidentifier: 1.0.1
+ http-errors@2.0.1:
+ dependencies:
+ depd: 2.0.0
+ inherits: 2.0.4
+ setprototypeof: 1.2.0
+ statuses: 2.0.2
+ toidentifier: 1.0.1
+
human-signals@2.1.0: {}
iconv-lite@0.6.3:
@@ -6025,6 +6847,10 @@ snapshots:
'@formatjs/icu-messageformat-parser': 2.11.2
tslib: 2.8.1
+ ip-address@10.0.1: {}
+
+ ipaddr.js@1.9.1: {}
+
is-alphabetical@2.0.1: {}
is-alphanumerical@2.0.1:
@@ -6054,6 +6880,8 @@ snapshots:
is-promise@2.2.2: {}
+ is-promise@4.0.0: {}
+
is-stream@2.0.1: {}
is-wsl@2.2.0:
@@ -6071,12 +6899,16 @@ snapshots:
jiti@2.5.1: {}
+ jose@6.1.3: {}
+
js-tokens@4.0.0: {}
jsesc@3.1.0: {}
json-schema-traverse@1.0.0: {}
+ json-schema-typed@8.0.2: {}
+
json5@2.2.3: {}
katex@0.16.22:
@@ -6233,6 +7065,8 @@ snapshots:
splaytree: 0.1.4
tinyqueue: 1.2.3
+ math-intrinsics@1.1.0: {}
+
mdast-util-directive@3.1.0:
dependencies:
'@types/mdast': 4.0.4
@@ -6417,6 +7251,10 @@ snapshots:
dependencies:
'@types/mdast': 4.0.4
+ media-typer@1.1.0: {}
+
+ merge-descriptors@2.0.0: {}
+
merge-stream@2.0.0: {}
merge2@1.4.1: {}
@@ -6682,6 +7520,10 @@ snapshots:
dependencies:
mime-db: 1.52.0
+ mime-types@3.0.2:
+ dependencies:
+ mime-db: 1.54.0
+
mimic-fn@2.1.0: {}
minimatch@3.1.2:
@@ -6726,6 +7568,8 @@ snapshots:
negotiator@0.6.3: {}
+ negotiator@1.0.0: {}
+
node-releases@2.0.19: {}
normalize-range@0.1.2: {}
@@ -6734,8 +7578,20 @@ snapshots:
dependencies:
path-key: 3.1.1
+ object-assign@4.1.1: {}
+
+ object-inspect@1.13.4: {}
+
+ on-finished@2.4.1:
+ dependencies:
+ ee-first: 1.1.1
+
on-headers@1.0.2: {}
+ once@1.4.0:
+ dependencies:
+ wrappy: 1.0.2
+
onetime@5.1.2:
dependencies:
mimic-fn: 2.1.0
@@ -6756,6 +7612,8 @@ snapshots:
dependencies:
entities: 6.0.1
+ parseurl@1.3.3: {}
+
partial-json@0.1.7: {}
path-data-parser@0.1.0: {}
@@ -6766,6 +7624,8 @@ snapshots:
path-to-regexp@3.3.0: {}
+ path-to-regexp@8.3.0: {}
+
pathe@2.0.3: {}
pbf@4.0.1:
@@ -6778,6 +7638,8 @@ snapshots:
picomatch@4.0.3: {}
+ pkce-challenge@5.0.1: {}
+
pkg-types@1.3.1:
dependencies:
confbox: 0.1.8
@@ -6824,8 +7686,17 @@ snapshots:
protocol-buffers-schema@3.6.0: {}
+ proxy-addr@2.0.7:
+ dependencies:
+ forwarded: 0.2.0
+ ipaddr.js: 1.9.1
+
punycode@2.3.1: {}
+ qs@6.14.1:
+ dependencies:
+ side-channel: 1.1.0
+
quansync@0.2.10: {}
queue-microtask@1.2.3: {}
@@ -6897,6 +7768,8 @@ snapshots:
range-parser@1.2.0: {}
+ range-parser@1.2.1: {}
+
raw-body@3.0.1:
dependencies:
bytes: 3.1.2
@@ -7157,6 +8030,16 @@ snapshots:
points-on-curve: 0.2.0
points-on-path: 0.2.1
+ router@2.2.0:
+ dependencies:
+ debug: 4.4.3
+ depd: 2.0.0
+ is-promise: 4.0.0
+ parseurl: 1.3.3
+ path-to-regexp: 8.3.0
+ transitivePeerDependencies:
+ - supports-color
+
run-parallel@1.2.0:
dependencies:
queue-microtask: 1.2.3
@@ -7175,6 +8058,22 @@ snapshots:
semver@6.3.1: {}
+ send@1.2.1:
+ dependencies:
+ debug: 4.4.3
+ encodeurl: 2.0.0
+ escape-html: 1.0.3
+ etag: 1.8.1
+ fresh: 2.0.0
+ http-errors: 2.0.1
+ mime-types: 3.0.2
+ ms: 2.1.3
+ on-finished: 2.4.1
+ range-parser: 1.2.1
+ statuses: 2.0.2
+ transitivePeerDependencies:
+ - supports-color
+
serialize-to-js@3.1.2: {}
serve-handler@6.1.6:
@@ -7187,6 +8086,15 @@ snapshots:
path-to-regexp: 3.3.0
range-parser: 1.2.0
+ serve-static@2.2.1:
+ dependencies:
+ encodeurl: 2.0.0
+ escape-html: 1.0.3
+ parseurl: 1.3.3
+ send: 1.2.1
+ transitivePeerDependencies:
+ - supports-color
+
serve@14.2.4:
dependencies:
'@zeit/schemas': 2.36.0
@@ -7213,6 +8121,34 @@ snapshots:
shebang-regex@3.0.0: {}
+ side-channel-list@1.0.0:
+ dependencies:
+ es-errors: 1.3.0
+ object-inspect: 1.13.4
+
+ side-channel-map@1.0.1:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+ object-inspect: 1.13.4
+
+ side-channel-weakmap@1.0.2:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+ object-inspect: 1.13.4
+ side-channel-map: 1.0.1
+
+ side-channel@1.1.0:
+ dependencies:
+ es-errors: 1.3.0
+ object-inspect: 1.13.4
+ side-channel-list: 1.0.0
+ side-channel-map: 1.0.1
+ side-channel-weakmap: 1.0.2
+
signal-exit@3.0.7: {}
source-map-js@1.2.1: {}
@@ -7230,6 +8166,8 @@ snapshots:
statuses@2.0.1: {}
+ statuses@2.0.2: {}
+
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
@@ -7368,6 +8306,12 @@ snapshots:
type-fest@2.19.0: {}
+ type-is@2.0.1:
+ dependencies:
+ content-type: 1.0.5
+ media-typer: 1.1.0
+ mime-types: 3.0.2
+
typescript@5.9.2: {}
ufo@1.6.1: {}
@@ -7531,10 +8475,20 @@ snapshots:
string-width: 5.1.2
strip-ansi: 7.1.0
+ wrappy@1.0.2: {}
+
yallist@3.1.1: {}
yallist@5.0.0: {}
+ zod-to-json-schema@3.25.1(zod@3.25.76):
+ dependencies:
+ zod: 3.25.76
+
+ zod-to-json-schema@3.25.1(zod@4.1.5):
+ dependencies:
+ zod: 4.1.5
+
zod@3.25.76: {}
zod@4.1.5: {}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 6aa003d8..25ce0b6f 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -1,3 +1,4 @@
packages:
- pizzaz_server_node
- kitchen_sink_server_node
+ - cards_against_ai_server_node
diff --git a/src/cards-against-ai/App.tsx b/src/cards-against-ai/App.tsx
new file mode 100644
index 00000000..dc316ba4
--- /dev/null
+++ b/src/cards-against-ai/App.tsx
@@ -0,0 +1,305 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import { useApp } from "@modelcontextprotocol/ext-apps/react";
+import type { App as McpApp } from "@modelcontextprotocol/ext-apps/react";
+import { PlayArea } from "./PlayArea";
+import { SplashScreen } from "./SplashScreen";
+import { getApiBaseUrl } from "./api-base-url";
+import type { GameState, NextActionHint } from "./types";
+
+/**
+ * Sends a message to the model, preferring `sendFollowUpMessage` (scrolls to
+ * bottom) with a fallback to `app.sendMessage` for hosts that don't support window.openai
+ */
+async function sendFollowUp(
+ app: McpApp,
+ text: string,
+): Promise {
+ const openai = window.openai;
+ if (openai?.sendFollowUpMessage) {
+ try {
+ await openai.sendFollowUpMessage({ prompt: text, scrollToBottom: true });
+ return;
+ } catch (err) {
+ console.warn("[cards-ai] sendFollowUpMessage failed, falling back to sendMessage", err);
+ }
+ }
+ await app.sendMessage({
+ role: "user",
+ content: [{ type: "text", text }],
+ });
+}
+
+/**
+ * Owns ALL game state and actions. Two data channels feed state updates:
+ * 1. `ontoolresult` — fires on every tool response (bug fix: now updates gameState)
+ * 2. SSE — server pushes full gameState on every change
+ *
+ * Both channels call `updateGameState`, which sets state AND clears pending
+ * UI flags in a single synchronous batch — no useEffect needed.
+ */
+function useCardsAgainstAIGame() {
+ const [gameId, setGameId] = useState(null);
+ const [gameState, setGameState] = useState(null);
+ const [pendingPlayCardId, setPendingPlayCardId] = useState(null);
+ const [pendingJudge, setPendingJudge] = useState(false);
+ const [pendingNextRound, setPendingNextRound] = useState(false);
+ const [lastNextAction, setLastNextAction] = useState(null);
+ const pendingActionRef = useRef(false);
+
+ // Sets gameState and clears pending UI states in one batch.
+ const updateGameState = useCallback((state: GameState) => {
+ setGameState(state);
+ setPendingPlayCardId(null);
+ setPendingJudge(false);
+ setPendingNextRound(false);
+ setLastNextAction(null);
+ }, []);
+
+ const onAppCreated = useCallback((app: McpApp) => {
+ app.ontoolresult = (params) => {
+ const sc = params.structuredContent as
+ | { gameId?: string; gameState?: GameState }
+ | undefined;
+ if (sc?.gameId) setGameId(sc.gameId);
+ if (sc?.gameState) updateGameState(sc.gameState);
+ };
+ }, [updateGameState]);
+
+ const { app } = useApp({
+ appInfo: { name: "cards-against-ai", version: "1.0.0" },
+ capabilities: {},
+ onAppCreated,
+ });
+
+ // SSE — server pushes full gameState on every change.
+ useEffect(() => {
+ if (!gameId) return;
+
+ let cancelled = false;
+ let es: EventSource | null = null;
+ let reconnectTimeout: ReturnType | null = null;
+
+ const connect = () => {
+ if (cancelled) return;
+ // Close previous EventSource before opening a new one
+ es?.close();
+ if (reconnectTimeout) {
+ clearTimeout(reconnectTimeout);
+ reconnectTimeout = null;
+ }
+
+ const baseUrl = getApiBaseUrl();
+ const url = `${baseUrl}/mcp/game/${gameId}/state-stream`;
+ es = new EventSource(url);
+
+ es.onmessage = (event) => {
+ try {
+ const data = JSON.parse(event.data) as {
+ gameState?: GameState;
+ nextAction?: NextActionHint;
+ };
+ if (data.gameState) {
+ updateGameState(data.gameState);
+ }
+ if (data.nextAction) {
+ setLastNextAction(data.nextAction);
+ }
+ } catch {
+ console.warn("[cards-ai] SSE message parse error", event.data);
+ }
+ };
+
+ es.onerror = () => {
+ console.error("[cards-ai] SSE connection error (reconnecting...)");
+ // Close to disable browser auto-reconnect
+ es?.close();
+ es = null;
+ if (cancelled) return;
+ reconnectTimeout = setTimeout(connect, 5000);
+ };
+ };
+
+ connect();
+
+ return () => {
+ cancelled = true;
+ es?.close();
+ if (reconnectTimeout) {
+ clearTimeout(reconnectTimeout);
+ }
+ };
+ }, [gameId, updateGameState]);
+
+ // Watchdog: if the model ignores a notifyModel hint, nudge it after 15s.
+ useEffect(() => {
+ if (!lastNextAction?.notifyModel || !app || !gameId) return;
+ const staleStatus = gameState?.status;
+ const timer = setTimeout(() => {
+ // Only nudge if state hasn't progressed
+ if (gameState?.status === staleStatus) {
+ sendFollowUp(
+ app,
+ `[GAME ACTION REQUIRED] ${lastNextAction.description}\nThe game is waiting on you. Take the action above NOW.\nWrite a brief line of in-character dialog from a CPU player while you do it.`,
+ );
+ setLastNextAction(null);
+ }
+ }, 15_000);
+ return () => clearTimeout(timer);
+ }, [lastNextAction, gameState?.status, app, gameId]);
+
+ // Safety net: auto-clear pendingNextRound if the model never calls submit-prompt.
+ useEffect(() => {
+ if (!pendingNextRound) return;
+ const id = setTimeout(() => setPendingNextRound(false), 15_000);
+ return () => clearTimeout(id);
+ }, [pendingNextRound]);
+
+ // --- Game actions ---
+
+ const callToolAndNotify = useCallback(
+ async (
+ toolName: string,
+ args: Record,
+ humanActionSummary: string,
+ ) => {
+ if (!app) return;
+ const result = await app.callServerTool({
+ name: toolName,
+ arguments: args,
+ });
+ const sc = result?.structuredContent as
+ | { nextAction?: { notifyModel?: boolean; description?: string } | null; cpuContext?: unknown }
+ | undefined;
+
+ if (sc?.nextAction?.notifyModel) {
+ const cpuContextStr = sc.cpuContext
+ ? `\n\nCPU Context:\n${JSON.stringify(sc.cpuContext, null, 2)}`
+ : "";
+ await sendFollowUp(
+ app,
+ `${humanActionSummary}\n\n${sc.nextAction.description}${cpuContextStr}\n\nStay in character. Write a brief quip or reaction from each CPU player as they take their action.`,
+ );
+ }
+ },
+ [app],
+ );
+
+ const playCard = useCallback(
+ async (cardId: string, playerId: string) => {
+ if (pendingActionRef.current || !app || !gameId) return;
+ pendingActionRef.current = true;
+ setPendingPlayCardId(cardId);
+ try {
+ await callToolAndNotify(
+ "play-answer-card",
+ { gameId, playerId, cardId },
+ `I played answer card ${cardId}.`,
+ );
+ } catch (err) {
+ console.error("[cards-ai] playCard failed", err);
+ setPendingPlayCardId(null);
+ } finally {
+ pendingActionRef.current = false;
+ }
+ },
+ [app, gameId, callToolAndNotify],
+ );
+
+ const judgeCard = useCallback(
+ async (winningCardId: string, judgeId: string) => {
+ if (pendingActionRef.current || !app || !gameId) return;
+ pendingActionRef.current = true;
+ setPendingJudge(true);
+ try {
+ await callToolAndNotify(
+ "judge-answer-card",
+ { gameId, playerId: judgeId, winningCardId },
+ `I judged card ${winningCardId} as the winner.`,
+ );
+ } catch (err) {
+ console.error("[cards-ai] judgeCard failed", err);
+ setPendingJudge(false);
+ } finally {
+ pendingActionRef.current = false;
+ }
+ },
+ [app, gameId, callToolAndNotify],
+ );
+
+ const nextRound = useCallback(async () => {
+ if (pendingActionRef.current || !app || !gameId || !gameState) return;
+ pendingActionRef.current = true;
+ setPendingNextRound(true);
+ try {
+ // Build context so the model knows exactly what submit-prompt needs.
+ const judge = gameState.players[gameState.currentJudgePlayerIndex];
+ const playersWhoPlayed = gameState.playedAnswerCards
+ .map((p) => gameState.players.find((pl) => pl.id === p.playerId))
+ .filter((p): p is NonNullable => p != null);
+ const previousPrompts = gameState.discardedPromptCards.map((p) => p.text);
+
+ const contextLines = [
+ `The human clicked "Next Round". The game is in "${gameState.status}" state.`,
+ `Call the submit-prompt tool NOW for gameId="${gameId}".`,
+ "",
+ `Judge this round: ${judge?.persona?.name ?? judge?.id} (${judge?.id})`,
+ `Players who need a replacement answer card: ${playersWhoPlayed.map((p) => `${p.persona?.name ?? p.id} (${p.id})`).join(", ")}`,
+ ...(previousPrompts.length > 0
+ ? [`Previous prompts (do NOT repeat): ${previousPrompts.join("; ")}`]
+ : []),
+ "",
+ `Add a line or two of between-round banter from the CPU players — reactions to last round, trash-talk, or hype for the next prompt.`,
+ ];
+
+ await sendFollowUp(app, contextLines.join("\n"));
+ } catch (err) {
+ console.error("[cards-ai] nextRound failed", err);
+ setPendingNextRound(false);
+ } finally {
+ pendingActionRef.current = false;
+ }
+ }, [app, gameId, gameState]);
+
+ return {
+ gameState, app,
+ playCard, judgeCard, nextRound,
+ pendingPlayCardId, pendingJudge, pendingNextRound,
+ } as const;
+}
+
+export default function App() {
+ const {
+ gameState, app,
+ playCard, judgeCard, nextRound,
+ pendingPlayCardId, pendingJudge, pendingNextRound,
+ } = useCardsAgainstAIGame();
+ const [pipStarted, setPipStarted] = useState(false);
+
+ if (!pipStarted) {
+ return (
+ {
+ app?.requestDisplayMode({ mode: "pip" });
+ setPipStarted(true);
+ }}
+ />
+ );
+ }
+
+ if (!gameState) {
+ return
Loading...
;
+ }
+
+ return (
+
+ );
+}
diff --git a/src/cards-against-ai/Cards.tsx b/src/cards-against-ai/Cards.tsx
new file mode 100644
index 00000000..090d6c4e
--- /dev/null
+++ b/src/cards-against-ai/Cards.tsx
@@ -0,0 +1,186 @@
+import { useEffect, useState } from "react";
+import { getAssetsBaseUrl } from "./api-base-url";
+import cardBackPattern from "./assets/card-back-pattern.png";
+
+/**
+ * The width and height of a card in pixels.
+ */
+export const CARD_WIDTH = 138;
+export const CARD_HEIGHT = 193;
+/**
+ * The position of the card in the dealer's hand. This is used
+ * as an "offscreen" position for cards that are being dealt, or discarded.
+ */
+export const CARD_DEALER_SPOT = {
+ x: -CARD_WIDTH,
+ y: -CARD_HEIGHT,
+ rotation: 0,
+} as const;
+
+const assetsBaseUrl = getAssetsBaseUrl();
+const cardBackPatternUrl = assetsBaseUrl
+ ? new URL(cardBackPattern, assetsBaseUrl).toString()
+ : cardBackPattern;
+
+export interface CardProps {
+ x: number;
+ y: number;
+ rotation: number;
+ faceUp: boolean;
+ children: React.ReactNode;
+}
+
+const baseFaceClasses =
+ "flex h-full w-full items-start rounded-2xl border border-black bg-white bg-gradient-to-b from-slate-50 to-white px-3 py-2.5 text-left text-black outline-none";
+
+/**
+ * A base card component that is used to get the general layout and positioning of a card.
+ * The child components are displayed on the face of the card. All animations, flipping, etc,
+ * are controlled with CSS transitions and/or keyframe animations.
+ */
+export function Card({ x, y, rotation, faceUp, children }: CardProps) {
+ // All cards start in the dealer's hand.
+ const [actualX, setActualX] = useState(CARD_DEALER_SPOT.x);
+ const [actualY, setActualY] = useState(CARD_DEALER_SPOT.y);
+ const [actualRotation, setActualRotation] = useState(0);
+ const [actualFaceUp, setActualFaceUp] = useState(false);
+
+ useEffect(() => {
+ // After render, we need to update the actual positions of the card so that the animation can start.
+ const id = requestAnimationFrame(() => {
+ setActualX(x);
+ setActualY(y);
+ setActualRotation(rotation);
+ setActualFaceUp(faceUp);
+ });
+ return () => cancelAnimationFrame(id);
+ }, [x, y, rotation, faceUp]);
+
+ return (
+
+ {/* Card inner (flip) */}
+
+ {/* Front face */}
+
+
+ {children}
+
+
+ {/* Back face */}
+
+
+
+
+
+ );
+}
+
+export interface AnswerCardProps extends Omit {
+ /**
+ * The answer text to display on the card.
+ */
+ text: string;
+ /**
+ * The card ID, required when interactive is true.
+ */
+ cardId?: string;
+ /**
+ * Whether the card is interactive. If true, the card will be clickable
+ */
+ interactive?: boolean;
+ /**
+ * The function to call when the card is clicked.
+ * Only works if interactive is true.
+ */
+ onClick?: (event: { cardId: string }) => void;
+ /**
+ * Whether the card should be visually highlighted (e.g. winning card).
+ */
+ highlighted?: boolean;
+}
+
+/**
+ * An answer card that is used to display answer text to the players,
+ * as well as to allow the player to interact with the card (if interactive is true).
+ */
+export function AnswerCard({
+ x,
+ y,
+ rotation,
+ faceUp,
+ interactive,
+ cardId,
+ text,
+ onClick,
+ highlighted,
+}: AnswerCardProps) {
+ const highlightClass = highlighted
+ ? " scale-105 [animation:cards-ai-winner-glow_1.8s_ease-in-out_infinite]"
+ : "";
+ const highlightStyle: React.CSSProperties | undefined = highlighted
+ ? { borderColor: "transparent" }
+ : undefined;
+ return (
+
+ {interactive ? (
+
+ ) : (
+
+ {text}
+
+ )}
+
+ );
+}
+
+export interface PromptCardProps extends Omit {
+ /**
+ * The prompt text to display.
+ */
+ text: string;
+ children?: React.ReactNode;
+}
+
+/**
+ * A prompt card that is used to display promp text to the players.
+ */
+export function PromptCard({
+ x,
+ y,
+ rotation,
+ faceUp,
+ text,
+ children,
+}: PromptCardProps) {
+ return (
+
+
+ {text}
+ {children}
+
+
+ );
+}
diff --git a/src/cards-against-ai/DESIGN.md b/src/cards-against-ai/DESIGN.md
new file mode 100644
index 00000000..1240142c
--- /dev/null
+++ b/src/cards-against-ai/DESIGN.md
@@ -0,0 +1,59 @@
+# Cards Against AI — Architecture
+
+## MCP Apps Protocol
+
+Uses `@modelcontextprotocol/ext-apps` — widget communicates via `postMessage` (JSON-RPC), not `window.openai` globals.
+
+## Data Channels
+
+Hybrid approach — two mechanisms for widget→server communication, plus SSE for state delivery:
+
+1. **`callServerTool`** — direct tool calls that bypass the model. Used for `play-answer-card` and `judge-answer-card`. No confirmation dialog, instant execution.
+2. **`sendFollowUp`** — helper that prefers `window.openai.sendFollowUpMessage` (scrolls to bottom) with fallback to `app.sendMessage`. Routes through the model. Triggered when `nextAction.notifyModel` is `true` (e.g. after play-answer-card when CPU needs to act). Also used for `submit-prompt` (next round button) and the watchdog timer.
+3. **SSE** (`/mcp/game/:gameId/state-stream`) — server pushes full `gameState` on every state change. Single `EventSource` per game, opened when `gameId` is known.
+
+`ontoolresult` is kept solely for bootstrapping: it delivers the initial `gameId` from `start-game`, which opens the SSE connection.
+
+All tool responses include `_meta["openai/widgetSessionId"]` = gameId.
+
+## Game Loop
+
+```
+Human clicks answer card
+ → widget calls callServerTool("play-answer-card") → server updates state → SSE pushes
+ → if nextAction.notifyModel: widget sends sendMessage → LLM calls play-cpu-answer-cards → SSE pushes
+ → if nextAction is cpu-judge-answer-card: LLM calls cpu-judge-answer-card → SSE pushes
+ → if nextAction is human-judge-pending: widget shows judge UI (via SSE state)
+
+Human judges card
+ → widget calls callServerTool("judge-answer-card") → server updates state → SSE pushes
+ → if nextAction.notifyModel: widget sends sendMessage (currently no cases, but future-proof)
+
+Human clicks "Next Round"
+ → widget sends sendMessage("Call submit-prompt for gameId=...")
+ → LLM calls submit-prompt → server updates state → SSE pushes
+```
+
+## MCP Tools
+
+| Tool | Initiator | Purpose |
+|------|-----------|---------|
+| `start-game` | LLM | Create game with players, cards, first prompt |
+| `play-answer-card` | Widget (callServerTool) | Human plays a card (idempotent) |
+| `judge-answer-card` | Widget (callServerTool) | Human judge picks winner (idempotent) |
+| `play-cpu-answer-cards` | LLM (via sendMessage) | CPU players play their answer cards |
+| `cpu-judge-answer-card` | LLM (via sendMessage) | CPU judge picks the winning card |
+| `submit-prompt` | LLM (via sendMessage) | New prompt + replacement cards for next round |
+
+## Human as Judge
+
+When the human is the judge, the game loop differs:
+
+1. After `submit-prompt`, `nextAction` is `play-cpu-answer-cards` (not `human-answer-pending`).
+2. `cpuContext` is included in the response so the model immediately calls `play-cpu-answer-cards`.
+3. All CPU players play their cards, then the human judges via the widget UI.
+4. No `sendMessage` needed from the widget — the model acts on the tool response directly.
+
+## CPU Dialog
+
+CPU dialog is generated inline by the model in its response text — there is no separate banter tool. Tool descriptions instruct the model to write in-character quips, reactions, and between-round banter alongside each game action.
diff --git a/src/cards-against-ai/PlayArea.tsx b/src/cards-against-ai/PlayArea.tsx
new file mode 100644
index 00000000..137a6d2a
--- /dev/null
+++ b/src/cards-against-ai/PlayArea.tsx
@@ -0,0 +1,297 @@
+import { useEffect, useMemo, useRef, useState } from "react";
+import {
+ CARD_HEIGHT,
+ CARD_WIDTH,
+ AnswerCard,
+ PromptCard,
+ CARD_DEALER_SPOT,
+} from "./Cards";
+import { Scoreboard } from "./Scoreboard";
+import type { GameState } from "./types";
+
+interface Bounds {
+ width: number;
+ height: number;
+}
+
+const CARD_HAND_ROTATION_STEP = 4;
+const CARD_HAND_MAX_GAP = 60;
+const CARD_PLAYED_GAP = 12;
+const CARD_PROMPT_TOP_Y = 12;
+const CARD_HAND_BOTTOM_PADDING = 12;
+const ANSWER_CARDS_OFFSCREEN_POSITION_Y = CARD_HEIGHT + 10;
+
+/**
+ * Minimum container height: prompt row + played row + hand row + padding/gaps.
+ * top-pad(12) + card(193) + gap(20) + card(193) + gap(20) + card(193) + bottom-pad(12)
+ */
+const MIN_PLAY_AREA_HEIGHT =
+ CARD_PROMPT_TOP_Y + CARD_HEIGHT + 20 + CARD_HEIGHT + CARD_HAND_BOTTOM_PADDING;
+
+// --- Component ---
+
+export interface PlayAreaProps {
+ gameState: GameState;
+ playCard: (cardId: string, playerId: string) => void;
+ judgeCard: (winningCardId: string, judgeId: string) => void;
+ nextRound: () => void;
+ pendingPlayCardId: string | null;
+ pendingJudge: boolean;
+ pendingNextRound: boolean;
+}
+
+/**
+ * Responsible for displaying the game state.
+ * Does the work of figuring out where to position the cards,
+ * accounting for the status of the gameState, and making sure things
+ * are displayed correctly.
+ */
+export function PlayArea(props: PlayAreaProps) {
+ const {
+ gameState,
+ playCard, judgeCard, nextRound,
+ pendingPlayCardId, pendingJudge, pendingNextRound,
+ } = props;
+ const containerRef = useRef(null);
+ const [bounds, setBounds] = useState(null);
+ const previousPromptRef = useRef(null);
+ const answerCardsInPlay = useMemo(() => new Set(), []);
+
+ // --- ResizeObserver for container bounds ---
+ useEffect(() => {
+ const el = containerRef.current;
+ if (!el) return;
+ const observer = new ResizeObserver(([entry]) => {
+ const { width, height } = entry.contentRect;
+ setBounds((prev) =>
+ prev && prev.width === width && prev.height === height
+ ? prev
+ : { width, height }
+ );
+ });
+ observer.observe(el);
+ return () => observer.disconnect();
+ }, []);
+
+ // --- Build positioned card elements ---
+ const localPlayerId = getLocalPlayerId(gameState);
+
+ const positionedCards = useMemo(() => {
+ if (!bounds) return [];
+
+ const answerCardsNotInPlay = new Set(answerCardsInPlay);
+ answerCardsInPlay.clear();
+
+ const elements: React.ReactNode[] = [];
+
+ // Add prompt card
+ if (gameState.prompt) {
+ const isNewlyAddedPromptCard =
+ previousPromptRef.current !== gameState.prompt?.id;
+ previousPromptRef.current = gameState.prompt?.id;
+
+ if (isNewlyAddedPromptCard) {
+ // The old card is going to the dealer's hand.
+ elements.push(
+
+ );
+ }
+
+ const position = {
+ x: (bounds.width - CARD_WIDTH) / 2,
+ y: CARD_PROMPT_TOP_Y,
+ rotation: 0,
+ };
+
+ elements.push(
+
+ );
+ }
+
+ const localPlayer = gameState.players.find((p) => p.id === localPlayerId);
+
+ if (!localPlayer) throw new Error("Local player not found");
+
+ const localPlayerIsJudge =
+ localPlayer.id ===
+ gameState.players[gameState.currentJudgePlayerIndex].id;
+ const localPlayerHasPlayedACard =
+ pendingPlayCardId !== null ||
+ gameState.playedAnswerCards.some(
+ (played) => played.playerId === localPlayer.id
+ );
+
+ // Add played answer cards
+ if (gameState.playedAnswerCards.length > 0) {
+ elements.push(
+ ...gameState.playedAnswerCards.map((played, index) => {
+ const playedCard = gameState.answerCards[played.cardId];
+
+ if (playedCard) {
+ answerCardsInPlay.add(played.cardId);
+ answerCardsNotInPlay.delete(played.cardId);
+
+ const position = {
+ x:
+ CARD_WIDTH * index +
+ bounds.width / 2 -
+ CARD_WIDTH * 1.5 -
+ CARD_PLAYED_GAP +
+ CARD_PLAYED_GAP * index,
+ y: CARD_PROMPT_TOP_Y + CARD_HEIGHT + 20,
+ rotation: 0,
+ };
+
+ return (
+ judgeCard(cardId, localPlayer.id)}
+ {...position}
+ faceUp={true}
+ text={playedCard.text}
+ />
+ );
+ }
+ })
+ );
+ }
+
+ // Add cards in hand.
+ for (let index = 0; index < localPlayer.answerCards.length; index++) {
+ const cardId = localPlayer.answerCards[index];
+ const card = gameState.answerCards[cardId];
+
+ if (card) {
+ answerCardsInPlay.add(cardId);
+ answerCardsNotInPlay.delete(cardId);
+
+ const isOffscreen = localPlayerIsJudge || localPlayerHasPlayedACard;
+ const isInteractive = !localPlayerIsJudge && !localPlayerHasPlayedACard;
+
+ const position = {
+ x:
+ (bounds.width -
+ CARD_WIDTH -
+ CARD_HAND_MAX_GAP * (localPlayer.answerCards.length - 1)) /
+ 2 +
+ CARD_HAND_MAX_GAP * index,
+ y:
+ bounds.height -
+ CARD_HAND_BOTTOM_PADDING -
+ CARD_HEIGHT +
+ (isOffscreen ? ANSWER_CARDS_OFFSCREEN_POSITION_Y : 0),
+ rotation:
+ (index - (localPlayer.answerCards.length - 1) / 2) *
+ CARD_HAND_ROTATION_STEP,
+ };
+
+ elements.push(
+ playCard(cardId, localPlayer.id)}
+ {...position}
+ faceUp={true}
+ text={card.text}
+ />
+ );
+ }
+ }
+
+ for (const cardId of answerCardsNotInPlay) {
+ // Move the answer cards not in play to the dealer's hand.
+ const card = gameState.answerCards[cardId];
+ if (card) {
+ elements.push(
+
+ );
+ }
+ }
+
+ return elements;
+ }, [gameState, bounds, playCard, judgeCard, pendingPlayCardId, pendingJudge]);
+
+ const { players, currentJudgePlayerIndex, status, winnerId } = gameState;
+
+ // Find winner info for announce-winner overlay
+ const winner =
+ status === "announce-winner" && winnerId
+ ? players.find((p) => p.id === winnerId)
+ : null;
+
+ return (
+
+ );
+}
+
+function getLocalPlayerId(gameState: GameState): string | null {
+ return gameState.players.find((p) => p.type === "human")?.id ?? null;
+}
diff --git a/src/cards-against-ai/README.md b/src/cards-against-ai/README.md
new file mode 100644
index 00000000..c77f76ad
--- /dev/null
+++ b/src/cards-against-ai/README.md
@@ -0,0 +1,18 @@
+# Cards Against AI — Widget Code
+
+This widget demonstrates key MCP Apps patterns for building interactive UIs that communicate with both an MCP server and ChatGPT's model.
+
+## Key Concepts
+
+- **Widget initialization** — [`useApp()`](./App.tsx#L36) sets up the MCP Apps postMessage/JSON-RPC connection to the host (ChatGPT).
+- **Bootstrapping from tool results** — [`ontoolresult`](./App.tsx#L23) fires on every tool response. Used once to extract the `gameId` from `start-game`.
+- **Real-time state via SSE** — [`useStreamingGameState`](./App.tsx#L53) opens an EventSource for live game state updates, independent of tool calls.
+- **Direct tool calls** — [`callServerTool`](./PlayArea.tsx#L101) calls the MCP server directly, bypassing the model. Instant, no confirmation dialog.
+- **Model-mediated actions** — [`sendMessage`](./PlayArea.tsx#L180) sends a message into the conversation so the model can decide what to do next.
+- **Hybrid pattern** — [`callToolAndNotify`](./PlayArea.tsx#L92) combines both: direct call first, then conditionally notifies the model based on `nextAction.notifyModel`.
+- **Display modes** — [`requestDisplayMode({ mode: "pip" })`](./App.tsx#L115) keeps the widget visible in picture-in-picture while the user chats.
+- **Routing signal** — [`NextActionHint.notifyModel`](./types.ts#L68) tells the widget whether the model needs to act next or if it should wait for human input.
+
+## Architecture
+
+See [DESIGN.md](./DESIGN.md) for the full data flow and game loop.
diff --git a/src/cards-against-ai/Scoreboard.tsx b/src/cards-against-ai/Scoreboard.tsx
new file mode 100644
index 00000000..392c4a73
--- /dev/null
+++ b/src/cards-against-ai/Scoreboard.tsx
@@ -0,0 +1,60 @@
+import { useMemo } from "react";
+import type { Player } from "./types";
+
+interface ScoreboardProps {
+ players: Player[];
+ currentJudgePlayerIndex: number;
+ localPlayerId: string | null;
+}
+
+export function Scoreboard({
+ players,
+ currentJudgePlayerIndex,
+ localPlayerId,
+}: ScoreboardProps) {
+ const sortedPlayers = useMemo(() => {
+ const entries = players.map((player, index) => ({
+ player,
+ originalIndex: index,
+ }));
+ entries.sort(
+ (a, b) =>
+ b.player.wonPromptCards.length - a.player.wonPromptCards.length,
+ );
+ return entries;
+ }, [players]);
+
+ return (
+