import { Command } from 'commander'; import { generateCardDeck, type GenerateCardDeckParams, type CardField } from '../tools/generate-card-deck.js'; import { readFrontmatter, type ReadFrontmatterParams, type DeckFrontmatter } from '../tools/frontmatter/read-frontmatter.js'; import { writeFrontmatter, type WriteFrontmatterParams } from '../tools/frontmatter/write-frontmatter.js'; import { cardCrud, type CardCrudParams, type CardData } from '../tools/card/card-crud.js'; import { ensureDeckPreview, type EnsureDeckPreviewParams } from '../tools/ensure-deck-preview.js'; import { designCardGame, getDesignCardGamePrompt, type DesignCardGameOptions } from '../prompts/design-card-game.js'; import { populateDeck, getPopulateDeckPrompt, type PopulateDeckOptions } from '../prompts/populate-deck.js'; import { setupDeckDisplay, getSetupDeckDisplayPrompt, type SetupDeckDisplayOptions } from '../prompts/setup-deck-display.js'; import { listResources, readResource, type DocResource } from '../resources/docs.js'; /** * MCP 服务器命令 * * 提供 MCP (Model Context Protocol) 服务器功能,用于与 AI 助手集成 */ export interface MCPOptions { port?: string; cwd?: string; } export const mcpCommand = new Command('mcp') .description('MCP 服务器 - 用于 AI 助手集成的工具协议') .addCommand( new Command('serve') .description('启动 MCP 服务器') .argument('[host]', '服务器地址', 'stdio') .option('-p, --port ', 'HTTP 端口(仅 HTTP 传输)', '3001') .option('--cwd ', '工作目录(工具调用的相对路径基准)', process.env.TTRPG_MCP_CWD || process.cwd()) .action(mcpServeAction) ) .addCommand( new Command('generate-card-deck') .description('生成卡牌组(快速命令)') .requiredOption('--name ', '卡牌组名称') .requiredOption('--output ', '输出目录') .option('-c, --count ', '卡牌数量', '10') .option('--fields ', '字段列表(逗号分隔)', 'name,type,cost,description') .option('--description ', '卡牌组描述') .option('--size ', '卡牌尺寸', '54x86') .option('--grid ', '网格布局', '5x8') .action(generateCardDeckAction) ); /** * MCP 服务器启动处理函数 */ async function mcpServeAction(host: string, options: MCPOptions) { // 切换到指定的工作目录 const cwd = options.cwd || process.cwd(); process.chdir(cwd); console.error(`MCP 服务器工作目录:${cwd}`); // 动态导入 MCP SDK const { Server } = await import('@modelcontextprotocol/sdk/server/index.js'); const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js'); const { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, } = await import('@modelcontextprotocol/sdk/types.js'); const server = new Server( { name: 'ttrpg-card-generator', version: '0.1.0', }, { capabilities: { tools: {}, prompts: {}, resources: {}, }, } ); // 处理工具列表请求 server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: 'deck_frontmatter_read', description: '读取 CSV 文件的 frontmatter(包含模板定义和 deck 配置)', inputSchema: { type: 'object', properties: { csv_file: { type: 'string', description: 'CSV 文件路径(相对路径相对于 MCP 服务器工作目录)', }, }, required: ['csv_file'], }, }, { name: 'deck_frontmatter_write', description: '写入/更新 CSV 文件的 frontmatter(模板定义和 deck 配置)', inputSchema: { type: 'object', properties: { csv_file: { type: 'string', description: 'CSV 文件路径', }, frontmatter: { type: 'object', description: '要写入的 frontmatter 数据', properties: { fields: { type: 'array', description: '字段定义列表', items: { type: 'object', properties: { name: { type: 'string', description: '字段名称' }, description: { type: 'string', description: '字段描述' }, examples: { type: 'array', items: { type: 'string' }, description: '示例值列表' }, }, required: ['name'], }, }, deck: { type: 'object', description: 'Deck 配置', properties: { size: { type: 'string', description: '卡牌尺寸,格式 "宽 x 高"' }, grid: { type: 'string', description: '网格布局,格式 "列 x 行",表示卡牌排版区域分成多少行多少列,用于显示字段。默认使用5x8。' }, bleed: { type: 'number', description: '出血边距(mm)' }, padding: { type: 'number', description: '内边距(mm)' }, shape: { type: 'string', enum: ['rectangle', 'circle', 'hex', 'diamond'] }, layers: { type: 'string', description: '字段的显示图层配置。如`title:1,1-5,1f8n body:1,3-5,8f3n`表示将title字段的内容,显示在第1列1行到第5列1行的区域,8mm字体,上侧朝向北,body覆盖1列3行到5列行,3mm字体,上侧朝向北。通常希望重要的辨识字段放在左侧,如`rank:1,1-1,1f6 suit:1,2-1,2f6`。' }, back_layers: { type: 'string', description: '背面显示图层配置,与正面类似' }, }, }, }, }, merge: { type: 'boolean', description: '是否合并现有 frontmatter(默认 true)', default: true, }, }, required: ['csv_file', 'frontmatter'], }, }, { name: 'deck_card_crud', description: '卡牌 CRUD 操作(创建/读取/更新/删除),支持批量操作', inputSchema: { type: 'object', properties: { csv_file: { type: 'string', description: 'CSV 文件路径', }, action: { type: 'string', description: '操作类型', enum: ['create', 'read', 'update', 'delete'], }, cards: { type: ['array', 'object'], description: '卡牌数据(单张或数组)', items: { type: 'object', additionalProperties: { type: 'string', description: "字段内容可以使用markdown语法。使用:[attack]来表示名为attack的图标。使用{{prop}}来引用另一个字段,或者frontmatter里的内容。" }, }, }, label: { type: ['string', 'array'], description: '要操作的卡牌 label(用于 read/update/delete)', items: { type: 'string' }, }, }, required: ['csv_file', 'action'], }, }, { name: 'deck_ensure_preview', description: '确保 CSV 对应的 Markdown 预览文件存在', inputSchema: { type: 'object', properties: { csv_file: { type: 'string', description: 'CSV 文件路径', }, md_file: { type: 'string', description: 'Markdown 文件路径(可选,默认与 CSV 同名)', }, title: { type: 'string', description: '标题(可选,默认从 CSV 文件名推断)', }, description: { type: 'string', description: '描述(可选)', }, }, required: ['csv_file'], }, }, ], }; }); // 处理 Prompts 列表请求 server.setRequestHandler(ListPromptsRequestSchema, async () => { return { prompts: [ getDesignCardGamePrompt(), getPopulateDeckPrompt(), getSetupDeckDisplayPrompt(), ], }; }); // 处理工具调用请求 server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { switch (name) { case 'generate_card_deck': { const params = args as unknown as GenerateCardDeckParams; // 验证必需参数 if (!params.deck_name || !params.output_dir) { return { content: [ { type: 'text', text: '错误:缺少必需参数 deck_name 或 output_dir', }, ], isError: true, }; } // 生成卡牌组(使用当前工作目录) const result = generateCardDeck(params); return { content: [ { type: 'text', text: result.message, }, { type: 'text', text: `\n## 生成的组件代码\n\n\`\`\`markdown\n${result.deckComponent}\n\`\`\``, }, ], }; } case 'deck_frontmatter_read': { const params = args as unknown as ReadFrontmatterParams; if (!params.csv_file) { return { content: [{ type: 'text', text: '错误:缺少必需参数 csv_file' }], isError: true, }; } const result = readFrontmatter(params); return { content: [ { type: 'text', text: result.message, }, ...(result.frontmatter ? [{ type: 'text', text: `\n## Frontmatter\n\n\`\`\`json\n${JSON.stringify(result.frontmatter, null, 2)}\n\`\`\``, }] : []), ], }; } case 'deck_frontmatter_write': { const params = args as unknown as WriteFrontmatterParams; if (!params.csv_file || !params.frontmatter) { return { content: [{ type: 'text', text: '错误:缺少必需参数 csv_file 或 frontmatter' }], isError: true, }; } const result = writeFrontmatter(params); return { content: [{ type: 'text', text: result.success ? `✅ ${result.message}` : `❌ ${result.message}` }], isError: !result.success, }; } case 'deck_card_crud': { const params = args as unknown as CardCrudParams; if (!params.csv_file || !params.action) { return { content: [{ type: 'text', text: '错误:缺少必需参数 csv_file 或 action' }], isError: true, }; } const result = cardCrud(params); return { content: [ { type: 'text', text: result.success ? `✅ ${result.message}` : `❌ ${result.message}`, }, ...(result.cards && result.cards.length > 0 ? [{ type: 'text', text: `\n## 卡牌数据\n\n\`\`\`json\n${JSON.stringify(result.cards, null, 2)}\n\`\`\``, }] : []), ], isError: !result.success, }; } case 'deck_ensure_preview': { const params = args as unknown as EnsureDeckPreviewParams; if (!params.csv_file) { return { content: [{ type: 'text', text: '错误:缺少必需参数 csv_file' }], isError: true, }; } const result = ensureDeckPreview(params); return { content: [{ type: 'text', text: result.success ? `✅ ${result.message}` : `❌ ${result.message}` }], isError: !result.success, }; } default: return { content: [{ type: 'text', text: `未知工具:${name}` }], isError: true, }; } } catch (error) { return { content: [{ type: 'text', text: `工具调用失败:${error instanceof Error ? error.message : '未知错误'}` }], isError: true, }; } }); // 处理 Prompts 获取请求 server.setRequestHandler(GetPromptRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { switch (name) { case 'design-card-game': { const options = args as unknown as DesignCardGameOptions; const result = designCardGame(options); return { description: '引导用户设计新的卡牌游戏系统,定义卡牌模板和字段结构', messages: result.messages, }; } case 'populate-deck': { const options = args as unknown as PopulateDeckOptions; const result = populateDeck(options); return { description: '为已有的卡牌组生成和填充卡牌内容', messages: result.messages, }; } case 'setup-deck-display': { const options = args as unknown as SetupDeckDisplayOptions; const result = setupDeckDisplay(options); return { description: '引导用户配置卡牌的显示参数(尺寸、布局、样式等)', messages: result.messages, }; } default: throw new Error(`未知 prompt:${name}`); } } catch (error) { throw error; } }); // 处理 Resources 列表请求 server.setRequestHandler(ListResourcesRequestSchema, async () => { return { resources: listResources().map(r => ({ uri: r.uri, name: r.name, title: r.title, description: r.description, mimeType: r.mimeType, })), }; }); // 处理 Resources 读取请求 server.setRequestHandler(ReadResourceRequestSchema, async (request) => { const { uri } = request.params; const resource = readResource(uri, process.cwd()); if (!resource) { throw new Error(`Resource not found: ${uri}`); } return { contents: [resource], }; }); // 启动服务器 if (host === 'stdio') { const transport = new StdioServerTransport(); await server.connect(transport); console.error('TTRPG 卡牌生成 MCP 服务器已启动(stdio)'); } else { // TODO: 支持 HTTP 传输 console.error('HTTP 传输模式尚未实现,请使用 stdio 模式'); process.exit(1); } } /** * 快速生成卡牌组命令处理函数 */ async function generateCardDeckAction(options: { name: string; output: string; count: string; fields: string; description?: string; size: string; grid: string; }) { const fieldNames = options.fields.split(',').map(f => f.trim()); const template = { fields: fieldNames.map((name): CardField => ({ name, examples: ['示例 1', '示例 2'] })) }; const params: GenerateCardDeckParams = { deck_name: options.name, output_dir: options.output, card_count: parseInt(options.count, 10), card_template: template, deck_config: { size: options.size, grid: options.grid }, description: options.description }; const result = generateCardDeck(params); console.log('\n✅ 卡牌组生成成功!\n'); console.log(result.message); console.log('\n📦 组件代码:'); console.log(` ${result.deckComponent}\n`); }