UNPKG

rpg-mcp-server

Version:

LLM-driven RPG game server using MCP protocol

451 lines (445 loc) 18.1 kB
#!/usr/bin/env node import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js'; import { GameManager } from './gameManager.js'; import { createUIResource } from '@mcp-ui/server'; /** * RPG 게임 MCP 서버 * 5개의 Tool 제공: createGame, updateGame, getGame, progressStory, promptUserActions */ class RPGMCPServer { server; gameManager; constructor() { this.server = new Server({ name: 'rpg-mcp-server', version: '1.0.0', }, { capabilities: { tools: {}, }, }); this.gameManager = new GameManager(); this.setupHandlers(); } setupHandlers() { // Tool 호출 처리 this.server.setRequestHandler(CallToolRequestSchema, async (request) => { const toolName = request.params.name; const toolArgs = request.params.arguments || {}; console.error(`Tool called: ${toolName}`, toolArgs); // 디버깅용 로그 try { let result; switch (toolName) { case 'createGame': result = await this.handleCreateGame(toolArgs); break; case 'updateGame': result = await this.handleUpdateGame(toolArgs); break; case 'getGame': result = await this.handleGetGame(toolArgs); break; case 'progressStory': result = await this.handleProgressStory(toolArgs); break; case 'promptUserActions': result = await this.handlePromptUserActions(toolArgs); break; case 'selectAction': result = await this.handleSelectAction(toolArgs); break; default: throw new Error(`Unknown tool: ${toolName}`); } console.error(`Tool ${toolName} executed successfully`); // 성공 로그 return result; } catch (error) { console.error('Tool execution error:', { name: toolName, args: toolArgs, error: error.message, stack: error.stack, }); const errorResponse = { error: error.message, tool: toolName, timestamp: new Date().toISOString(), }; return { content: [ { type: 'text', text: JSON.stringify(errorResponse, null, 2), }, ], isError: true, }; } }); // 사용 가능한 Tool 목록 this.server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: 'createGame', description: 'Create a new RPG game with complete initial state. Returns the created game with assigned gameId.', inputSchema: { type: 'object', properties: { initialStateInJson: { type: 'object', description: 'Complete game state object. Must include title, characters array, and game world settings. Example: {"title": "Fantasy Adventure", "characters": [{"name": "Hero", "level": 1, "hp": 100}], "world": {"location": "Village", "time": "morning"}}', examples: [ { title: 'Fantasy Adventure', characters: [{ name: 'Hero', level: 1, hp: 100, mp: 50, class: 'Warrior' }], world: { location: 'Starting Village', time: 'morning', weather: 'sunny' }, inventory: [], story: { chapter: 1, progress: 'beginning' }, }, ], }, }, required: ['initialStateInJson'], }, }, { name: 'updateGame', description: 'Update a specific field in the game state and return the complete updated game state. Supports nested property updates using path notation.', inputSchema: { type: 'object', properties: { gameId: { type: 'string', description: 'ID of the game to update', }, fieldSelector: { type: 'string', description: "Path to the field to update. Examples: 'characters[0].level', 'world.time', 'player.stats.hp', 'characters[1].favorability'", }, value: { type: ['string', 'number', 'object', 'array', 'boolean', 'null'], description: 'New value to set (supports all types: string, number, object, array, boolean, null)', examples: [ 5, 'new location', { hp: 100, mp: 50 }, ['item1', 'item2'], true, null, ], }, }, required: ['gameId', 'fieldSelector', 'value'], }, }, { name: 'getGame', description: 'Retrieve the complete current state of a game by its ID.', inputSchema: { type: 'object', properties: { gameId: { type: 'string', description: 'ID of the game to retrieve', }, }, required: ['gameId'], }, }, { name: 'progressStory', description: 'Progress the game story. Advances the narrative and sets the current story progress. This should be called after createGame or updateGame to continue the story flow.', inputSchema: { type: 'object', properties: { gameId: { type: 'string', description: 'ID of the game to progress', }, progress: { type: 'string', description: 'Description of the current story progress or event. Should describe what happens next in the narrative.', }, }, required: ['gameId', 'progress'], }, }, { name: 'promptUserActions', description: 'Prompt the user with available action options. This completes the story progression cycle and waits for user input before the next updateGame call.', inputSchema: { type: 'object', properties: { gameId: { type: 'string', description: 'ID of the game to prompt', }, options: { type: 'array', items: { type: 'string' }, description: 'List of available user action options. Must contain at least one option.', minItems: 1, }, }, required: ['gameId', 'options'], }, }, { name: 'selectAction', description: "Process user's selected action from UI and continue game flow", inputSchema: { type: 'object', properties: { gameId: { type: 'string', description: 'ID of the game' }, selectedOption: { type: 'string', description: 'The option text user selected' }, selectedIndex: { type: 'number', description: 'Index of selected option (0-based)', }, }, required: ['gameId', 'selectedOption', 'selectedIndex'], }, }, ], }; }); } async handleCreateGame(params) { if (!params.initialStateInJson) { throw new Error('initialStateInJson parameter is required'); } const result = this.gameManager.createGame(params.initialStateInJson); return { content: [ { type: 'text', text: JSON.stringify(result, null, 2), }, ], }; } async handleUpdateGame(params) { if (!params.gameId || !params.fieldSelector || params.value === undefined) { throw new Error('gameId, fieldSelector, and value parameters are required'); } const result = this.gameManager.updateGame(params.gameId, params.fieldSelector, params.value); return { content: [ { type: 'text', text: JSON.stringify(result, null, 2), }, ], }; } async handleGetGame(params) { if (!params.gameId) { throw new Error('gameId parameter is required'); } const result = this.gameManager.getGame(params.gameId); return { content: [ { type: 'text', text: JSON.stringify(result, null, 2), }, ], }; } async handleProgressStory(params) { if (!params.gameId || typeof params.progress !== 'string') { throw new Error('gameId and progress parameters are required'); } const result = this.gameManager.progressStory(params.gameId, params.progress); return { content: [ { type: 'text', text: JSON.stringify(result, null, 2), }, ], }; } async handlePromptUserActions(params) { if (!params.gameId || !Array.isArray(params.options)) { throw new Error('gameId and options parameters are required'); } if (params.options.length === 0) { throw new Error('options array cannot be empty'); } const result = this.gameManager.promptUserActions(params.gameId, params.options); // UI 리소스를 수동으로 생성 (mimeType 포함) const uiHtml = this.generateGameUI(result.game.state.lastStoryProgress || '게임이 시작됩니다...', params.options, params.gameId); const uiResource = createUIResource({ uri: `ui://rpg-game/${params.gameId}/actions`, content: { htmlString: uiHtml, type: 'rawHtml' }, encoding: 'blob', }); return { content: [ uiResource, { type: 'text', text: JSON.stringify(result, null, 2), }, ], }; } /** * 게임 UI HTML 생성 */ generateGameUI(storyProgress, options, gameId) { const optionButtons = options .map((option, index) => ` <button class="action-button" onclick="selectAction('${gameId}', '${option.replace(/'/g, "\\'")}', ${index})" > ${option} </button> `) .join(''); return ` <!DOCTYPE html> <html lang="ko"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>RPG Game - ${gameId}</title> <style> body { font-family: 'Arial', sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #333; min-height: 100vh; } .game-container { background: white; border-radius: 15px; padding: 30px; box-shadow: 0 10px 30px rgba(0,0,0,0.2); } .story-section { background: #f8f9fa; border-left: 5px solid #667eea; padding: 20px; margin-bottom: 30px; border-radius: 5px; font-size: 16px; line-height: 1.6; } .actions-section h3 { color: #667eea; margin-bottom: 20px; font-size: 18px; } .action-button { display: block; width: 100%; padding: 15px 20px; margin-bottom: 10px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; border: none; border-radius: 8px; font-size: 16px; cursor: pointer; transition: all 0.3s ease; text-align: left; } .action-button:hover { transform: translateY(-2px); box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4); } .action-button:active { transform: translateY(0); } .game-id { color: #666; font-size: 12px; text-align: center; margin-top: 20px; } </style> </head> <body> <div class="game-container"> <div class="story-section"> ${storyProgress} </div> <div class="actions-section"> <h3>🎯 선택하세요:</h3> ${optionButtons} </div> <div class="game-id">Game ID: ${gameId}</div> </div> <script> function selectAction(gameId, selectedOption, selectedIndex) { // MCP 도구 호출을 시뮬레이션 const result = { tool: 'selectAction', params: { gameId: gameId, selectedOption: selectedOption, selectedIndex: selectedIndex }, timestamp: new Date().toISOString() }; // 선택된 버튼 하이라이트 const buttons = document.querySelectorAll('.action-button'); buttons.forEach(btn => btn.style.opacity = '0.5'); buttons[selectedIndex].style.opacity = '1'; buttons[selectedIndex].style.background = 'linear-gradient(135deg, #28a745 0%, #20c997 100%)'; // MCP 도구 호출을 위한 postMessage window.parent.postMessage({ type: 'tool', payload: { toolName: 'selectAction', params: { gameId: gameId, selectedOption: selectedOption, selectedIndex: selectedIndex } } }, '*'); } </script> </body> </html>`; } async handleSelectAction(params) { if (!params.gameId || !params.selectedOption || params.selectedIndex === undefined) { throw new Error('gameId, selectedOption, and selectedIndex parameters are required'); } const result = this.gameManager.selectAction(params.gameId, params.selectedOption, params.selectedIndex); return { content: [ { type: 'text', text: JSON.stringify(result, null, 2), }, ], }; } /** * 서버 시작 */ async run() { const transport = new StdioServerTransport(); await this.server.connect(transport); console.error('RPG MCP Server started successfully'); // stderr로 출력 (MCP 프로토콜과 겹치지 않음) } } // 서버 실행 const server = new RPGMCPServer(); server.run().catch((error) => { console.error('Server startup error:', error); process.exit(1); });