|
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); |
|
}); |