Skip to content

Instantly share code, notes, and snippets.

@ochafik
Last active October 16, 2025 16:03
Show Gist options
  • Select an option

  • Save ochafik/3b812d08c2609d46b8355dec01b65c8d to your computer and use it in GitHub Desktop.

Select an option

Save ochafik/3b812d08c2609d46b8355dec01b65c8d to your computer and use it in GitHub Desktop.
MCP Adventure

This example demonstrates a role play game server that uses MCP sampling and elicitation to implement "choose your own adventure" games.

Usage: let's play pokemon w/ the game server

  • Claude Code:

    claude mcp add game -- \
      docker run --rm -i node:latest npx -y --silent "https://gist.github.com/ochafik/3b812d08c2609d46b8355dec01b65c8d"
  • Claude Desktop:

    {
      "mcpServers": {
        "game": {
          "command": "docker",
          "args": [
            "run",
            "--rm",
            "-i",
            "node:latest",
            "npx",
            "-y",
            "--silent",
            "https://gist.github.com/ochafik/3b812d08c2609d46b8355dec01b65c8d"
          ]
        }
      }
    }
  • Inspector:

    npx -y @modelcontextprotocol/inspector -- \
      docker run --rm -i node:latest npx -y --silent "https://gist.github.com/ochafik/3b812d08c2609d46b8355dec01b65c8d"
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
import { z } from "zod";
import type {
SamplingMessage,
CreateMessageResult,
CreateMessageRequest,
CallToolResult,
ElicitRequest,
} from "@modelcontextprotocol/sdk/types.js";
import { ElicitResultSchema, CreateMessageResultSchema } from "@modelcontextprotocol/sdk/types.js";
/**
* Parses markdown game output into structured data
* Expected format:
* # Update
* <story text>
* # Decisions (optional, mutually exclusive with End)
* - Decision 1
* - Decision 2 (timeout: 10 sec)
* # End (optional, mutually exclusive with Decisions)
* <You Win|You Lose|The End|To be continued...>
*/
interface ParsedGameOutput {
storyUpdate: string;
decisions: Array<{ text: string; timeoutSeconds?: number }>;
endStatus?: string;
}
function parseMarkdownGameOutput(markdown: string): ParsedGameOutput {
const result: ParsedGameOutput = {
storyUpdate: "",
decisions: [],
};
// Split by headers
const updateMatch = markdown.match(/# Update\s*\n([\s\S]*?)(?=\n#|$)/);
const decisionsMatch = markdown.match(/# Decisions\s*\n([\s\S]*?)(?=\n#|$)/);
const endMatch = markdown.match(/# End\s*\n([\s\S]*?)$/);
// Extract update
if (updateMatch) {
result.storyUpdate = updateMatch[1].trim();
}
// Extract decisions
if (decisionsMatch) {
const decisionsText = decisionsMatch[1].trim();
const decisionLines = decisionsText.split('\n').filter(line => line.trim().startsWith('- '));
for (const line of decisionLines) {
// Remove leading "- "
let decisionText = line.trim().substring(2);
let timeoutSeconds: number | undefined;
// Check for timeout pattern: (timeout: N sec)
const timeoutMatch = decisionText.match(/\s*\(timeout:\s*(\d+)\s*sec\)\s*$/);
if (timeoutMatch) {
timeoutSeconds = parseInt(timeoutMatch[1], 10);
decisionText = decisionText.substring(0, timeoutMatch.index).trim();
}
result.decisions.push({ text: decisionText, timeoutSeconds });
}
}
// Extract end status
if (endMatch) {
result.endStatus = endMatch[1].trim();
}
return result;
}
function makeErrorCallToolResult(error: any): CallToolResult {
return {
content: [
{
type: "text",
text: error instanceof Error ? `${error.message}\n${error.stack}` : `${error}`,
},
],
isError: true,
}
}
// Create and configure MCP server
const mcpServer = new McpServer({
name: "adventure-game",
version: "1.0.0",
});
// Register the localResearch tool that uses sampling with a tool loop
mcpServer.registerTool(
"choose_your_own_adventure_game",
{
description: "Play a game. The user will be asked for decisions along the way.",
inputSchema: {
gameSynopsisOrSubject: z
.string()
.describe(
"Description of the game subject or possible synopsis."
),
},
},
async ({ gameSynopsisOrSubject }, extra) => {
try {
const systemPrompt =
"You are a 'choose your own adventure' game master. " +
"Given an initial user request (subject and/or synopsis of the game, maybe description of their role in the game), " +
"you will relentlessly walk the user forward in an imaginary story, " +
"giving them regular choices as to what their character can do next. " +
"If the user didn't choose a role for themselves, you can ask them to pick one of a few interesting options (first decision). " +
"Then you will continually develop the story, giving story updates and asking for pivotal decisions. " +
"Updates should fit in a page (sometimes as short as a paragraph e.g. if doing a battle with very fast paced action). " +
"Some decisions should have a timeout to create some thrills for the user, in tight action scenes. " +
"The story must be immersive and exciting. The user must feel they are really there, in the middle of the action. " +
"The following output format must be respected precisely:\n" +
"# Update\n" +
"<A paragraph or two advancing the story, describing what happens as a result of the last decision (if any), and leading up to the next decision.>\n" +
"# Decisions\n" +
"<A list of possible decisions, each on its own line, starting with '- ', optionally ending with ` (timeout: N sec)`. If there are no decisions to be made, this section is missing>\n" +
"# End\n" +
"<One of 'The End', 'You Win', 'You Lose', or 'To be continued...'>\n" +
"\n" +
"(the End section is only there when the game ends, and is mutually exclusive with the Decisions section)\n";
const messages: SamplingMessage[] = [{
role: "user",
content: {
type: "text",
text: gameSynopsisOrSubject,
},
}];
let gameOver = false;
let fullTranscript = "";
const debugTranscript: any[] = [];
while (!gameOver) {
// Request AI to generate next part of the story
const createMessageRequest: CreateMessageRequest = {
method: 'sampling/createMessage',
params: {
messages,
systemPrompt,
maxTokens: 4096,
},
};
debugTranscript.push({ request: createMessageRequest });
const response = await extra.sendRequest(
createMessageRequest,
CreateMessageResultSchema
);
debugTranscript.push({ response });
// Extract text content from response
let responseText = "";
const contentArray = Array.isArray(response.content) ? response.content : [response.content];
for (const contentItem of contentArray) {
if (contentItem.type === 'text') {
responseText += contentItem.text;
}
}
// Parse the markdown output
const parsed = parseMarkdownGameOutput(responseText);
fullTranscript += parsed.storyUpdate + "\n\n";
// Add AI response to message history
messages.push({
role: "assistant",
content: {
type: "text",
text: responseText,
},
});
// Check for end status
if (parsed.endStatus) {
gameOver = true;
// Show final message to user
const finalMessage = parsed.endStatus + "\n\n" + parsed.storyUpdate;
await extra.sendRequest(<ElicitRequest>{
method: 'elicitation/create',
params: {
message: finalMessage,
requestedSchema: {
type: 'object',
properties: {},
},
},
}, ElicitResultSchema);
break;
}
// If there are decisions, elicit user choice
if (parsed.decisions.length > 0) {
// Find the decision with the shortest timeout (if any)
const timeouts = parsed.decisions
.map(d => d.timeoutSeconds)
.filter((t): t is number => t !== undefined);
const minTimeout = timeouts.length > 0 ? Math.min(...timeouts) : undefined;
const decisionTexts = parsed.decisions.map(d => d.text);
try {
const elicitRequest: ElicitRequest = {
method: 'elicitation/create',
params: {
message: parsed.storyUpdate,
requestedSchema: {
type: 'object',
properties: {
nextDecision: {
title: 'What do you do?',
type: 'string',
enum: decisionTexts,
},
},
required: ['nextDecision'],
},
},
};
debugTranscript.push({ elicitRequest });
const result = await extra.sendRequest(
elicitRequest,
ElicitResultSchema,
minTimeout !== undefined ? { timeout: minTimeout * 1000 } : undefined
);
debugTranscript.push({ elicitResult: result });
if (result.action === 'accept' && result.content?.nextDecision) {
// Add user's choice to message history
messages.push({
role: "user",
content: {
type: "text",
text: result.content.nextDecision as string,
},
});
} else {
// User declined or cancelled
gameOver = true;
fullTranscript += "\n[Game " + (result.action === 'decline' ? 'declined' : 'cancelled') + " by user]\n";
}
} catch (error) {
if (error instanceof McpError && error.code === ErrorCode.RequestTimeout) {
// Timeout occurred - send that info back to the AI
messages.push({
role: "user",
content: {
type: "text",
text: "[No decision made - timeout]",
},
});
debugTranscript.push({ timeout: true });
} else {
throw error;
}
}
} else {
// No decisions and no end status - this shouldn't happen, end game
gameOver = true;
}
}
return {
content: [
{
type: "text",
text: fullTranscript,
},
{
type: "text",
text: `\n\n--- Debug Transcript (${debugTranscript.length} events) ---\n${JSON.stringify(debugTranscript, null, 2)}`,
},
],
};
} catch (error) {
return makeErrorCallToolResult(error);
}
}
);
async function main() {
const transport = new StdioServerTransport();
await mcpServer.connect(transport);
console.error("'MCP Choose Your Own Adventure Game' Server is running...");
}
main().catch((error) => {
console.error("Server error:", error);
process.exit(1);
});
{
"name": "mcp-adventure",
"version": "1.0.0",
"description": "",
"license": "ISC",
"author": "",
"type": "module",
"bin": "dist/index.js",
"scripts": {
"prepare": "npm run build",
"start": "bun run index.ts",
"build": "bun build index.ts --outdir=dist --banner '#!/usr/bin/env node' --target=node --minify && shx chmod +x dist/index.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.19.1"
},
"devDependencies": {
"bun": "^1.2.23",
"shx": "^0.4.0"
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment