Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,10 @@ __pycache__/

# Ruff
.ruff_cache/

# AI Config
.claude/
CLAUDE.md

# ngrok
ngrok.yml
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
36 changes: 35 additions & 1 deletion build-all.mts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";

Expand All @@ -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",
Expand All @@ -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(
Expand Down Expand Up @@ -168,18 +179,41 @@ 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`);
const liveHtmlPath = path.join(dir, `${name}.html`);
const html = `<!doctype html>
<html>
<head>
<script>
window.__APP_URL_CONFIG__ = ${appUrlConfigJson};
</script>
<script type="module" src="${normalizedBaseUrl}/${name}-${h}.js"></script>
<link rel="stylesheet" href="${normalizedBaseUrl}/${name}-${h}.css">
</head>
Expand Down
23 changes: 23 additions & 0 deletions cards_against_ai_server_node/ANSWER_DECK_GUIDANCE.md
Original file line number Diff line number Diff line change
@@ -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.
43 changes: 43 additions & 0 deletions cards_against_ai_server_node/README.md
Original file line number Diff line number Diff line change
@@ -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.
223 changes: 223 additions & 0 deletions cards_against_ai_server_node/RULES.md
Original file line number Diff line number Diff line change
@@ -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!
Empty file.
Loading