ttrpg-tools/src/cli/commands/mcp.ts

502 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 <port>', 'HTTP 端口(仅 HTTP 传输)', '3001')
.option('--cwd <dir>', '工作目录(工具调用的相对路径基准)', process.env.TTRPG_MCP_CWD || process.cwd())
.action(mcpServeAction)
)
.addCommand(
new Command('generate-card-deck')
.description('生成卡牌组(快速命令)')
.requiredOption('--name <name>', '卡牌组名称')
.requiredOption('--output <dir>', '输出目录')
.option('-c, --count <number>', '卡牌数量', '10')
.option('--fields <fields>', '字段列表(逗号分隔)', 'name,type,cost,description')
.option('--description <desc>', '卡牌组描述')
.option('--size <size>', '卡牌尺寸', '54x86')
.option('--grid <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`);
}