UNPKG

kangjun-story-mcp

Version:

Kangjun Story MCP: Novel writing assistant MCP server with character and RPG equipment tools

1,264 lines 59.3 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 fs from "fs"; import path from "path"; import { fileURLToPath } from "url"; import { TEMPLATES } from "./templates.js"; import { LorebookManager } from "./lorebook.js"; import { StoryTools } from "./story-tools.js"; import { AdvancedStoryTools } from "./advanced-tools.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // ----- Utility helpers ----- function ensureDirSync(dir) { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); } function writeFileSafe(filePath, content, overwrite = false) { ensureDirSync(path.dirname(filePath)); if (!overwrite && fs.existsSync(filePath)) return false; fs.writeFileSync(filePath, content, "utf-8"); return true; } function nowISO() { return new Date().toISOString(); } function pad2(n) { return n.toString().padStart(2, "0"); } function readFileSafe(filePath) { try { return fs.readFileSync(filePath, "utf-8"); } catch { return null; } } function charCount(filePath) { try { const c = fs.readFileSync(filePath, "utf-8"); return c.length; } catch { return 0; } } // ---------- RNG for deterministic generation ---------- function mulberry32(seed) { return function () { let t = (seed += 0x6d2b79f5); t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } function pick(rng, arr) { return arr[Math.floor(rng() * arr.length)]; } function randInt(rng, min, max) { return Math.floor(rng() * (max - min + 1)) + min; } // ---------- Equipment generation ---------- const RARITIES = [ "커먼", "매직", "레어", "유니크", "에픽", "레전드", "미스틱", ]; const SLOTS = [ "weapon", "helmet", "chest", "gloves", "boots", "belt", "ring", "amulet", "shield", "pants", ]; const PREFIXES = { 커먼: ["낡은", "평범한", "실용적인", "묵직한"], 매직: ["비전의", "마력의", "신속한", "강화된"], 레어: ["숙련가의", "용맹한", "고대의", "정밀한"], 유니크: ["유일한", "전설의", "비밀스런", "영웅의"], 에픽: ["서사시의", "파도같은", "폭풍의", "광휘의"], 레전드: ["태초의", "불멸의", "운명의", "천둥의"], 미스틱: ["신화의", "심연의", "별빛의", "초월의"], }; const SUFFIXES = { weapon: ["검", "도끼", "활", "지팡이", "단검"], helmet: ["투구", "헬름"], chest: ["흉갑", "로브", "갑옷"], gloves: ["건틀릿", "장갑"], boots: ["각반", "부츠"], belt: ["벨트"], ring: ["반지"], amulet: ["아뮬렛", "목걸이"], shield: ["방패"], pants: ["각갑", "하의"], }; const AFFIX_POOL = { weapon: [ "+% 물리 피해", "+% 원소 피해", "+ 치명타 확률", "+ 치명타 피해", "+ 공격 속도", "+ 주문 시전 속도", "+ 정확도", ], helmet: [ "+ 최대 생명력", "+ 모든 저항", "+ 방어도", "+ 회피도", "+ 에너지 보호막", "+ 지능", ], chest: [ "+ 최대 생명력", "+ 모든 저항", "+ 방어도", "+ 회피도", "+ 에너지 보호막", ], gloves: [ "+ 방어도", "+ 회피도", "+ 공격 속도", "+ 정확도", "+ 스킬 쿨타임 감소", ], boots: [ "+ 이동 속도", "+ 모든 저항", "+ 최대 생명력", "+ 회피도", ], belt: [ "+ 최대 생명력", "+ 모든 저항", "+ 물약 회복량", "+ 힘", ], ring: [ "+ 모든 저항", "+ 최대 생명력", "+ 최대 마나", "+ 정확도", "+ 주문 시전 속도", "+ 치명타 확률", ], amulet: [ "+ 모든 능력치", "+ 스킬 레벨", "+ 치명타 피해", "+ 스킬 쿨타임 감소", "+ 원소 피해", ], shield: [ "+ 막기 확률", "+ 방어도", "+ 모든 저항", "+ 최대 생명력", ], pants: [ "+ 방어도", "+ 회피도", "+ 최대 생명력", "+ 모든 저항", ], }; function affixCountByRarity(rarity) { switch (rarity) { case "커먼": return [0, 1]; case "매직": return [1, 2]; case "레어": return [3, 4]; case "유니크": return [4, 6]; case "에픽": return [5, 6]; case "레전드": return [6, 8]; case "미스틱": return [7, 9]; } } function baseDefenseBySlot(slot, level) { const scale = Math.max(1, level); switch (slot) { case "helmet": return { armor: 10 * scale, evasion: 8 * scale, es: 6 * scale }; case "chest": return { armor: 20 * scale, evasion: 15 * scale, es: 12 * scale }; case "gloves": return { armor: 8 * scale, evasion: 8 * scale, es: 5 * scale }; case "boots": return { armor: 8 * scale, evasion: 10 * scale, es: 5 * scale }; case "belt": return { life: 15 * scale }; case "ring": return { res: 5 + Math.floor(scale / 2) }; case "amulet": return { stats: 2 + Math.floor(scale / 3) }; case "shield": return { block: 15 + Math.floor(scale / 2), armor: 12 * scale }; case "pants": return { armor: 12 * scale, evasion: 10 * scale }; default: return {}; } } function baseWeaponByLevel(level) { const min = 3 + Math.floor(level * 1.5); const max = min + 3 + Math.floor(level * 1.2); const aps = 1.0 + Math.min(0.7, level * 0.01); return { minDamage: min, maxDamage: max, attackPerSecond: +aps.toFixed(2) }; } function genItem(seed, slot, rarity, level, customName) { const rng = mulberry32(seed); const prefix = pick(rng, PREFIXES[rarity]); const baseName = customName ?? pick(rng, SUFFIXES[slot]); const name = `${prefix} ${baseName}`; const [minAff, maxAff] = affixCountByRarity(rarity); const affixCount = randInt(rng, minAff, maxAff); const affixes = []; const pool = AFFIX_POOL[slot]; const used = new Set(); for (let i = 0; i < affixCount; i++) { let idx = randInt(rng, 0, pool.length - 1); let tries = 0; while (used.has(idx) && tries++ < 10) idx = randInt(rng, 0, pool.length - 1); used.add(idx); const affix = pool[idx]; const value = 2 + Math.floor(level * (0.8 + rng() * 1.2)); affixes.push(`${affix} +${value}`); } let base = {}; if (slot === "weapon") base = baseWeaponByLevel(level); else base = baseDefenseBySlot(slot, level); const id = `${slot}-${rarity}-${Math.floor(rng() * 1e9).toString(36)}`; const item = { id, name, slot, rarity, level, base, affixes, createdAt: nowISO(), }; const mdLines = [ `# ${name}`, `- 슬롯: ${slot}`, `- 등급: ${rarity}`, `- 요구 레벨: ${level}`, `- 기본 수치: ${JSON.stringify(base)}`, `- 접두/접미 옵션:`, ...affixes.map((a) => ` - ${a}`), ]; return { item, markdown: mdLines.join("\n") }; } // ---------- Character Profile generation ---------- function fillCharacterTemplate(params) { const { name, age = "", gender = "", role = "", firstAppearance = "Chapter 1", pov = false, } = params; let tpl = TEMPLATES.characterTemplate; tpl = tpl.replace("[Name]", name); tpl = tpl.replace("Chapter X", firstAppearance); // Lightweight field hints at top to speed manual fill-in const header = `<!-- name:${name} age:${age} gender:${gender} role:${role} pov:${pov} -->\n`; return header + tpl; } // ---------- World/Story generation helpers ---------- function fillVars(tpl, vars) { let out = tpl; for (const [k, v] of Object.entries(vars)) { const re = new RegExp(`\\{${k}\\}`, "g"); out = out.replace(re, String(v)); } return out; } const GENRES = [ "판타지", "미스터리", "로맨스", "스릴러", "SF", "호러", "문학", ]; const TONES = ["어둡고 진지함", "밝고 경쾌함", "서정적", "건조하고 냉정함", "영웅적"]; const THEMES = [ "정체성과 선택", "희생과 구원", "권력과 책임", "기억과 망각", "사랑과 배신", "자유와 통제", "질서와 혼돈", ]; const EXTERNAL_CONFLICTS = [ "제국의 침공으로 고향이 위협받는다", "흉작과 전염병으로 지역 사회가 붕괴한다", "고대 봉인이 약해져 재앙이 깨어난다", "귀족 간 내전이 발발한다", "거대 기업의 착취가 임계점을 넘는다", ]; const INTERNAL_CONFLICTS = [ "자기 혐오와 두려움", "신뢰와 의심 사이의 갈등", "힘에 대한 중독", "과거의 죄책감", "가문에 대한 충성 vs 양심", ]; const ANTAGONISTS = [ "교단 지도자", "타락한 영웅", "권력중독 제후", "비밀결사의 조정자", "냉혹한 집정관", ]; const REWARDS = ["대지의 평화", "사랑하는 사람의 구원", "왕좌의 정당성", "진실의 공개", "저주 해방"]; const STAKES = ["도시 멸망", "종족 멸절", "세계 균형 붕괴", "개인 파멸", "영원한 전쟁"]; const MAGIC_LEVELS = ["없음", "미약", "중간", "강력"]; const TECH_LEVELS = ["고대", "중세", "산업", "근대", "현대", "미래"]; function randUnique(rng, arr, n) { const copy = [...arr]; const out = []; for (let i = 0; i < n && copy.length; i++) { const idx = Math.floor(rng() * copy.length); out.push(copy.splice(idx, 1)[0]); } return out; } function generateWorldbuilding(params) { const { rootDir, folderName = "소설책", title = "무제", author = "", genres = [], tone, worldName = "아르카디아", magicLevel, techLevel, targetWords = 80000, startDate = new Date().toISOString().slice(0, 10), deadline = "", tags = [], model = "three_act", chapters = 12, scenesPerChapter = 3, seed = 1337, overwrite = false, } = params; const rng = mulberry32(seed); const ensure = (p) => ensureDirSync(p); const root = path.resolve(rootDir, folderName); // Ensure base structure exists scaffoldProject(rootDir, folderName, true); const genresStr = Array.isArray(genres) ? genres.join(", ") : String(genres || pick(rng, GENRES)); const toneVal = tone || pick(rng, TONES); const theme1 = pick(rng, THEMES); const theme2 = pick(rng, THEMES.filter((t) => t !== theme1)); const theme3 = pick(rng, THEMES.filter((t) => t !== theme1 && t !== theme2)); const externalConflict = pick(rng, EXTERNAL_CONFLICTS); const internalConflict = pick(rng, INTERNAL_CONFLICTS); const antagonist = pick(rng, ANTAGONISTS); const reward = pick(rng, REWARDS); const stake = pick(rng, STAKES); const ml = magicLevel || pick(rng, MAGIC_LEVELS); const tl = techLevel || pick(rng, TECH_LEVELS); const tagsStr = Array.isArray(tags) ? tags.join(", ") : String(tags); const oneLiner = `${title}: ${worldName} 세계에서 ${externalConflict} 가운데, 주인공은 ${internalConflict}을(를) 극복해야 한다.`; const logline = `${title}: ${antagonist}에 맞서 ${stake}을(를) 막고 ${reward}을(를) 얻기 위한 여정.`; // Prepare directories const coreDir = path.join(root, "00-핵심관리"); const worldDir = path.join(root, "01-세계관"); const plotDir = path.join(root, "03-플롯"); ensure(coreDir); ensure(worldDir); ensure(plotDir); // Files const overview = fillVars(TEMPLATES.projectOverview, { title, author, genres: genresStr, tone: toneVal, targetWords, startDate, deadline, logline, theme1, theme2, theme3, oneLiner, audienceAge: "성인/일반", audienceTaste: "서사 중심, 캐릭터 성장", usp1: `${worldName} 특유의 규칙 기반 마법`, usp2: `${tl} 기술 수준과 ${ml} 마법의 공존`, usp3: `${antagonist}과(와)의 이념 충돌`, }); writeFileSafe(path.join(coreDir, "작품개요.md"), overview, overwrite); const style = fillVars(TEMPLATES.styleGuide, { pov: "third-limited", tense: "past", sentenceStyle: "중간 길이", vocabulary: "중간", emotionLevel: "중간", dialogueParticles: "과도 사용 금지, 감정 강조 시 제한적 허용", dialogueDiff: "핵심 키워드/억양/속도 차이", metaphor: "핵심 장면에서만 포인트로", }); writeFileSafe(path.join(coreDir, "스타일가이드.md"), style, overwrite); const rules = fillVars(TEMPLATES.worldRules, { physics: `${tl} 수준의 물리 법칙 준수`, magicSystem: `${ml} 마법, 비용과 제약 명시`, history: "대전(大戰) 이후 200년간 평화 유지", geography: "3대 대륙, 5대 왕국, 산맥/사막/빙설 지대 고정", culture: "지역별 관습 차이 허용", language: "공용어 1종 + 방언 3종", tech: `${tl}에서 점진 발전`, society: "전쟁/상업/신앙에 따라 변동", }); writeFileSafe(path.join(worldDir, "세계관규칙.md"), rules, overwrite); const mainPlot = fillVars(TEMPLATES.mainPlotBase, { model, chapters, scenesPerChapter, externalConflict, internalConflict, antagonist, goal: `안전을 되찾고 진실을 밝힌다`, stakes: stake, reward, beat_setup: `일상/세계 규칙 제시, ${externalConflict}`, beat_turn1: `사건에 휘말려 선택을 강요받음`, beat_mid: `진실의 일부와 배신 드러남`, beat_turn2: `모든 것이 무너지는 순간, 결단`, beat_climax: `${antagonist}과(와)의 대면과 승부`, beat_resolution: `새 균형과 변한 세계 제시`, }); writeFileSafe(path.join(plotDir, "메인플롯.md"), mainPlot, overwrite); const timeline = fillVars(TEMPLATES.timelineBase, { t0: `전설의 예언이 기록됨`, t1: `주인공의 조용한 일상 + 이상 징후`, t2: `사건 폭발, 여행 시작`, t3: `중간점에서 배신과 진실의 단서`, t4: `패배와 손실, 동료 분열`, t5: `최종 결전과 선택`, t6: `후일담, 세계의 변화와 여운`, }); writeFileSafe(path.join(coreDir, "타임라인.md"), timeline, overwrite); const regions = randUnique(rng, ["북대륙", "남대륙", "서부연안", "동부평원", "심연의 섬"], 3).join(", "); const factions = randUnique(rng, ["은빛 협정", "붉은 군단", "비전 길드", "검은 수호회", "무역 연합"], 3).join(", "); const species = randUnique(rng, ["인간", "엘프", "드워프", "오크", "혼혈"], 3).join(", "); const summary = `${worldName}는(은) ${tl} 기술과 ${ml} 마법이 공존하는 세계로, ${externalConflict}가(이) 도화선이 되어 영웅과 악역의 노선이 정면 충돌한다.`; const ws = fillVars(TEMPLATES.worldSeed, { worldName, genres: genresStr, tone: toneVal, techLevel: tl, magicLevel: ml, tags: tagsStr || `${theme1}, ${theme2}, ${antagonist}`, summary, regions, factions, species, }); writeFileSafe(path.join(worldDir, "world-seed.md"), ws, overwrite); return { root, created: true }; } function generateOutline(params) { const { rootDir, folderName = "소설책", model = "three_act", chapters = 12, scenesPerChapter = 3, seed = 1337, overwrite = false, } = params; const rng = mulberry32(seed); const root = path.resolve(rootDir, folderName); const plotDir = path.join(root, "03-플롯"); ensureDirSync(plotDir); const beatsByModel = { three_act: ["설정", "1전환", "대립", "2전환", "해결"], save_the_cat: [ "오프닝 이미지", "테마 제시", "설정", "촉매", "논쟁", "2막 진입", "B스토리", "즐거움과 게임", "중간점", "나쁜 놈들이 다가온다", "모든 것을 잃다", "영혼의 암흑기", "3막 진입", "피날레", "최종 이미지", ], heros_journey: [ "일상 세계", "모험의 부름", "부름의 거부", "멘토와의 만남", "첫 문턱 통과", "시험, 동료, 적", "가장 깊은 동굴 접근", "시련", "보상", "귀환의 길", "부활", "영약을 가지고 귀환", ], seven_point: [ "훅", "1전환", "핀치1", "중간점", "핀치2", "2전환", "해결", ], }; const beats = beatsByModel[model] || beatsByModel.three_act; const lines = []; lines.push(`# 스토리 아웃라인 (${model})`); lines.push(`총 ${chapters}장, 장면당 ${scenesPerChapter} 씬`); lines.push(""); lines.push("## 비트"); beats.forEach((b, i) => lines.push(`- [${i + 1}] ${b}`)); lines.push(""); lines.push("## 장별 개요"); for (let c = 1; c <= chapters; c++) { lines.push(`### Chapter ${c}`); for (let s = 1; s <= scenesPerChapter; s++) { const focus = pick(rng, ["갈등", "정보", "관계", "액션", "감정"]); const hook = pick(rng, ["불길한 조짐", "의외의 제안", "사라진 단서", "뜻밖의 조우", "급박한 소식"]); lines.push(`- Scene ${s}: 초점(${focus}), 훅(${hook})`); } lines.push(""); } const outPath = path.join(plotDir, `outline_${model}.md`); writeFileSafe(outPath, lines.join("\n"), overwrite); return { outline: outPath }; } // ---------- Project Scaffolding ---------- function scaffoldProject(rootDir, folderName = "소설책", overwrite = false) { const root = path.resolve(rootDir, folderName); const dirs = [ ".cursorrules", "00-핵심관리", "01-세계관/고유명사사전", "02-캐릭터/주인공", "02-캐릭터/조연", "02-캐릭터/엑스트라", "03-플롯", "04-원고/[권번호]/챕터", "04-원고/[권번호]/버전관리", "04-원고/[권번호]/삭제된씬", "05-심리추적/감정추적", "05-심리추적/동기맵", "06-리서치", "07-비즈니스", "08-피드백", "09-생산성", "10-시각자료/이미지프롬프트", "11-템플릿", ]; for (const d of dirs) ensureDirSync(path.join(root, d)); // Templates writeFileSafe(path.join(root, ".cursorrules/cursorrules.md"), TEMPLATES.cursorRules, overwrite); writeFileSafe(path.join(root, ".cursorrules/novel-brain.json"), TEMPLATES.novelBrainJson, overwrite); writeFileSafe(path.join(root, "11-템플릿/creative-prompts.md"), TEMPLATES.creativePrompts, overwrite); writeFileSafe(path.join(root, "10-시각자료/이미지프롬프트/prompt-template.md"), TEMPLATES.imagePromptTemplate, overwrite); writeFileSafe(path.join(root, "03-플롯/scene-checklist.md"), TEMPLATES.sceneChecklist, overwrite); writeFileSafe(path.join(root, "00-핵심관리/dashboard.md"), TEMPLATES.dashboard, overwrite); writeFileSafe(path.join(root, "02-캐릭터/캐릭터-템플릿.md"), TEMPLATES.characterTemplate, overwrite); writeFileSafe(path.join(root, "05-심리추적/감정추적/emotion-graph-template.md"), TEMPLATES.emotionGraphTemplate, overwrite); return { root }; } // ---------- MCP Server ---------- const server = new Server({ name: "kangjun story mcp", version: "0.1.0" }, { capabilities: { tools: {} } }); server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: "scaffold_project", description: "'소설책' 프로젝트 폴더 및 기본 템플릿(.cursorrules/템플릿 등) 생성", inputSchema: { type: "object", properties: { rootDir: { type: "string" }, folderName: { type: "string", default: "소설책" }, overwrite: { type: "boolean", default: false }, }, required: ["rootDir"], }, }, { name: "create_character_profile", description: "캐릭터 프로필 마크다운(템플릿 기반) 생성 및 선택적 저장", inputSchema: { type: "object", properties: { name: { type: "string" }, age: { type: "string" }, gender: { type: "string" }, role: { type: "string" }, firstAppearance: { type: "string" }, pov: { type: "boolean", default: false }, outputPath: { type: "string" }, overwrite: { type: "boolean", default: false }, }, required: ["name"], }, }, { name: "generate_equipment_item", description: "RPG 장비 아이템 생성(무기/방어구/헬멧/글로브/부츠/벨트/링/아뮬렛 등) + 등급(커먼~미스틱)", inputSchema: { type: "object", properties: { slot: { type: "string", enum: [...SLOTS] }, rarity: { type: "string", enum: [...RARITIES] }, level: { type: "integer", default: 1 }, seed: { type: "integer", default: 1337 }, name: { type: "string" }, writeFile: { type: "boolean", default: false }, outputDir: { type: "string" }, overwrite: { type: "boolean", default: false }, }, required: ["slot", "rarity"], }, }, { name: "list_rarities", description: "아이템 등급 목록 반환(커먼, 매직, 레어, 유니크, 에픽, 레전드, 미스틱)", inputSchema: { type: "object", properties: {} }, }, { name: "generate_worldbuilding", description: "스토리/세계관 시드 + 규칙 + 작품개요/스타일가이드/타임라인/메인플롯 자동 생성", inputSchema: { type: "object", properties: { rootDir: { type: "string" }, folderName: { type: "string", default: "소설책" }, title: { type: "string" }, author: { type: "string" }, genres: { anyOf: [{ type: "string" }, { type: "array", items: { type: "string" } }] }, tone: { type: "string" }, worldName: { type: "string" }, magicLevel: { type: "string" }, techLevel: { type: "string" }, targetWords: { type: "integer", default: 80000 }, startDate: { type: "string" }, deadline: { type: "string" }, tags: { anyOf: [{ type: "string" }, { type: "array", items: { type: "string" } }] }, model: { type: "string", default: "three_act" }, chapters: { type: "integer", default: 12 }, scenesPerChapter: { type: "integer", default: 3 }, seed: { type: "integer", default: 1337 }, overwrite: { type: "boolean", default: false }, }, required: ["rootDir"], }, }, { name: "generate_story_outline", description: "서사 구조 모델 기반 장별/씬별 아웃라인 자동 생성", inputSchema: { type: "object", properties: { rootDir: { type: "string" }, folderName: { type: "string", default: "소설책" }, model: { type: "string", enum: ["three_act", "save_the_cat", "heros_journey", "seven_point"], default: "three_act", }, chapters: { type: "integer", default: 12 }, scenesPerChapter: { type: "integer", default: 3 }, seed: { type: "integer", default: 1337 }, overwrite: { type: "boolean", default: false }, }, required: ["rootDir"], }, }, { name: "resume_progress", description: "프로젝트의 다음 작업을 자동 판별하고(분량/투두 기준) 필요한 파일/프롬프트를 생성하여 이어쓰기 준비", inputSchema: { type: "object", properties: { rootDir: { type: "string" }, folderName: { type: "string", default: "소설책" }, volume: { type: "integer", minimum: 1, maximum: 15 }, targetMinChars: { type: "integer", default: 10000 }, createScenePrompts: { type: "boolean", default: true }, dryRun: { type: "boolean", default: false }, }, required: ["rootDir"], }, }, // === Phase 1 새로운 기능들 === { name: "create_lorebook_entry", description: "Lorebook 엔트리 생성 - 캐릭터, 장소, 아이템, 사건, 개념 등 스토리 요소 저장", inputSchema: { type: "object", properties: { projectPath: { type: "string" }, type: { type: "string", enum: ["character", "location", "item", "event", "concept"] }, name: { type: "string" }, description: { type: "string" }, aliases: { type: "array", items: { type: "string" } }, tags: { type: "array", items: { type: "string" } }, relationships: { type: "object" }, attributes: { type: "object" }, notes: { type: "string" }, }, required: ["projectPath", "type", "name", "description"], }, }, { name: "search_lorebook", description: "Lorebook 검색 - 스토리 요소 검색 및 조회", inputSchema: { type: "object", properties: { projectPath: { type: "string" }, query: { type: "string" }, type: { type: "string", enum: ["character", "location", "item", "event", "concept"] }, }, required: ["projectPath", "query"], }, }, { name: "generate_dialogue", description: "캐릭터 대화 생성 - 감정과 톤을 반영한 대화 생성", inputSchema: { type: "object", properties: { character: { type: "string" }, emotion: { type: "string", enum: ["joy", "sadness", "anger", "fear", "surprise", "love", "tension"] }, tone: { type: "string", enum: ["formal", "casual", "angry", "sad", "excited", "nervous", "romantic"] }, context: { type: "string" }, targetCharacter: { type: "string" }, seed: { type: "integer" }, }, required: ["character"], }, }, { name: "suggest_plot_twist", description: "플롯 트위스트 제안 - 현재 스토리에 적합한 반전 아이디어 제공", inputSchema: { type: "object", properties: { currentContext: { type: "string" }, twistType: { type: "string", enum: ["betrayal", "hidden_identity", "revelation", "reversal", "false_victory", "sacrifice", "resurrection", "conspiracy", "time_twist", "relationship"] }, seed: { type: "integer" }, }, required: ["currentContext"], }, }, { name: "track_emotion", description: "캐릭터 감정 추적 - 캐릭터의 감정 상태 기록 및 추적", inputSchema: { type: "object", properties: { character: { type: "string" }, chapter: { type: "integer" }, scene: { type: "integer" }, emotions: { type: "object", properties: { joy: { type: "integer", minimum: 0, maximum: 10 }, sadness: { type: "integer", minimum: 0, maximum: 10 }, anger: { type: "integer", minimum: 0, maximum: 10 }, fear: { type: "integer", minimum: 0, maximum: 10 }, surprise: { type: "integer", minimum: 0, maximum: 10 }, love: { type: "integer", minimum: 0, maximum: 10 }, tension: { type: "integer", minimum: 0, maximum: 10 }, } }, context: { type: "string" }, }, required: ["character", "chapter", "scene", "emotions"], }, }, { name: "analyze_emotional_arc", description: "감정 변화 분석 - 캐릭터의 감정 변화 궤적 분석", inputSchema: { type: "object", properties: { character: { type: "string" }, startChapter: { type: "integer" }, endChapter: { type: "integer" }, }, required: ["character"], }, }, { name: "summarize_chapter", description: "챕터 요약 생성 - 챕터 내용의 자동 요약 및 핵심 정보 추출", inputSchema: { type: "object", properties: { chapterContent: { type: "string" }, maxLength: { type: "integer", default: 200 }, }, required: ["chapterContent"], }, }, { name: "check_consistency", description: "연속성 검사 - 스토리의 일관성과 플롯홀 검사", inputSchema: { type: "object", properties: { chapters: { type: "array", items: { type: "object", properties: { content: { type: "string" }, number: { type: "integer" }, }, required: ["content", "number"], }, }, }, required: ["chapters"], }, }, { name: "export_lorebook", description: "Lorebook 내보내기 - 마크다운 형식으로 Lorebook 내보내기", inputSchema: { type: "object", properties: { projectPath: { type: "string" }, outputPath: { type: "string" }, }, required: ["projectPath"], }, }, // === Phase 2 고급 기능들 === { name: "emulate_writer_style", description: "작가 스타일 에뮬레이션 - 헤밍웨이, 톨킨, 무라카미 등 유명 작가 문체 모방", inputSchema: { type: "object", properties: { text: { type: "string" }, styleName: { type: "string", enum: ["hemingway", "tolkien", "murakami", "king", "rowling", "christie", "custom"] }, customStyle: { type: "object", properties: { name: { type: "string" }, sentenceLength: { type: "string", enum: ["short", "medium", "long", "varied"] }, vocabulary: { type: "string", enum: ["simple", "moderate", "complex", "literary"] }, tone: { type: "string", enum: ["light", "serious", "humorous", "dark", "philosophical"] }, pacing: { type: "string", enum: ["fast", "moderate", "slow", "dynamic"] }, description: { type: "string", enum: ["minimal", "balanced", "rich", "verbose"] }, dialogue: { type: "string", enum: ["heavy", "balanced", "light"] }, } } }, required: ["text", "styleName"], }, }, { name: "brainstorm_ideas", description: "브레인스토밍 - 캐릭터, 플롯, 설정, 갈등, 테마, 제목 아이디어 생성", inputSchema: { type: "object", properties: { topic: { type: "string" }, type: { type: "string", enum: ["character", "plot", "setting", "conflict", "theme", "title"] }, quantity: { type: "integer", default: 5 }, seed: { type: "integer" }, constraints: { type: "array", items: { type: "string" } } }, required: ["topic", "type"], }, }, { name: "generate_alternatives", description: "다중 버전 생성 - 같은 장면의 여러 버전 생성 및 비교", inputSchema: { type: "object", properties: { originalText: { type: "string" }, type: { type: "string", enum: ["scene", "dialogue", "description", "opening", "ending"] }, variations: { type: "integer", default: 3 }, seed: { type: "integer" } }, required: ["originalText", "type"], }, }, { name: "deep_consistency_check", description: "심화 연속성 검사 - 캐릭터, 타임라인, 세계관 규칙 종합 검사", inputSchema: { type: "object", properties: { chapters: { type: "array", items: { type: "object", properties: { content: { type: "string" }, number: { type: "integer" } }, required: ["content", "number"] } }, characters: { type: "array", items: { type: "object", properties: { name: { type: "string" }, traits: { type: "array", items: { type: "string" } } }, required: ["name", "traits"] } }, timeline: { type: "array", items: { type: "object", properties: { event: { type: "string" }, chapter: { type: "integer" }, time: { type: "string" } }, required: ["event", "chapter", "time"] } }, worldRules: { type: "array", items: { type: "string" } } }, required: ["chapters"], }, }, ], }; }); server.setRequestHandler(CallToolRequestSchema, async (req) => { const name = req.params.name; const args = req.params.arguments ?? {}; try { if (name === "scaffold_project") { const { rootDir, folderName = "소설책", overwrite = false } = args; if (!rootDir || typeof rootDir !== "string") throw new Error("rootDir(string) 필요"); const { root } = scaffoldProject(rootDir, folderName, overwrite); return { content: [ { type: "text", text: `프로젝트 생성 완료: ${root}` }, ], }; } if (name === "create_character_profile") { const { name: charName, age, gender, role, firstAppearance, pov, outputPath, overwrite = false } = args; if (!charName) throw new Error("name 필요"); const md = fillCharacterTemplate({ name: charName, age, gender, role, firstAppearance, pov, }); if (outputPath && typeof outputPath === "string") { const outPath = path.resolve(outputPath); writeFileSafe(outPath, md, overwrite); return { content: [ { type: "text", text: `캐릭터 프로필 저장: ${outPath}` }, ], }; } return { content: [{ type: "text", text: md }] }; } if (name === "generate_equipment_item") { const { slot, rarity, level = 1, seed = 1337, name: displayName, writeFile = false, outputDir, overwrite = false, } = args; if (!SLOTS.includes(slot)) throw new Error(`slot은 ${SLOTS.join(", ")}`); if (!RARITIES.includes(rarity)) throw new Error(`rarity는 ${RARITIES.join(", ")}`); const { item, markdown } = genItem(seed, slot, rarity, level, displayName); if (writeFile && outputDir) { const dir = path.resolve(outputDir); ensureDirSync(dir); const safeName = `${item.rarity}_${item.slot}_${item.name.replace(/\s+/g, "-")}.md`; const outPath = path.join(dir, safeName); writeFileSafe(outPath, markdown, overwrite); return { content: [ { type: "text", text: JSON.stringify(item, null, 2) }, { type: "text", text: `파일 저장: ${outPath}` }, ], }; } return { content: [{ type: "text", text: JSON.stringify(item, null, 2) }] }; } if (name === "list_rarities") { const list = RARITIES.map((r, idx) => ({ name: r, tier: idx + 1 })); return { content: [{ type: "text", text: JSON.stringify(list, null, 2) }] }; } if (name === "generate_worldbuilding") { const result = generateWorldbuilding(args); return { content: [ { type: "text", text: `세계관/스토리 시드 생성 완료: ${result.root}` }, ], }; } if (name === "generate_story_outline") { const result = generateOutline(args); return { content: [{ type: "text", text: `아웃라인 생성: ${result.outline}` }] }; } if (name === "resume_progress") { const { rootDir, folderName = "소설책", volume, targetMinChars = 10000, createScenePrompts = true, dryRun = false, } = args; if (!rootDir || typeof rootDir !== "string") throw new Error("rootDir(string) 필요"); const root = path.resolve(rootDir, folderName); const manusDir = path.join(root, "04-원고"); const plotDir = path.join(root, "03-플롯"); const coreDir = path.join(root, "00-핵심관리"); // Scan volumes 1..15 const states = []; for (let v = 1; v <= 15; v++) { const nn = pad2(v); const dir = path.join(manusDir, nn); const manuscriptPath = path.join(dir, `권${nn}_통합_v1_초고.md`); const exists = fs.existsSync(manuscriptPath); const chars = exists ? charCount(manuscriptPath) : 0; const outlinePath = path.join(plotDir, `outline_vol${v}_three_act.md`); const hasOutline = fs.existsSync(outlinePath); const scenePromptsPath = path.join(plotDir, `권${nn}_씬-프롬프트.md`); const hasScenePrompts = fs.existsSync(scenePromptsPath); states.push({ vol: v, nn, manuscriptPath, exists, chars, outlinePath, hasOutline, scenePromptsPath, hasScenePrompts }); } // Update 분량-리포트.md (always computed fresh) const reportLines = ["# 분량 리포트(통합 초고 기준)"]; for (const st of states) { const mark = st.chars >= targetMinChars ? "✅" : (st.chars > 0 ? "⚠️" : "❌"); const entry = `- 권${st.nn}: ${st.chars}자 ${mark} (목표 ${targetMinChars}자+) — ${st.manuscriptPath}`; reportLines.push(entry); } if (!dryRun) { writeFileSafe(path.join(coreDir, "분량-리포트.md"), reportLines.join("\n"), true); } // Decide target volume let targetVol = volume; if (targetVol) { const startIdx = Math.max(0, states.findIndex((s) => s.vol === targetVol)); const needAfter = states.slice(startIdx).find((s) => s.chars < targetMinChars); const needAny = states.find((s) => s.chars < targetMinChars); targetVol = (needAfter ?? needAny)?.vol ?? targetVol; } else { const need = states.find((s) => s.chars < targetMinChars); targetVol = need?.vol ?? states.find((s) => !s.exists)?.vol ?? 1; } const tv = states.find((s) => s.vol === targetVol); // Ensure directories if (!dryRun) ensureDirSync(path.dirname(tv.manuscriptPath)); // If outline missing, generate generic outline file for this volume const generated = []; if (!tv.hasOutline && !dryRun) { // Reuse generic outline generator to seed, then write a per-volume file const gen = generateOutline({ rootDir, folderName, model: "three_act", overwrite: false }); const baseOutline = readFileSafe(gen.outline) || "# 스토리 아웃라인 (three_act)\n"; const volOutline = baseOutline .replace(/^# .*$/m, `# 권${tv.vol} 아웃라인 (3막 구조, 12장 × 3씬)`); writeFileSafe(tv.outlinePath, volOutline, false); generated.push(tv.outlinePath); } // If manuscript missing, create a header stub if (!tv.exists && !dryRun) { const header = `권${tv.nn} 통합 초고 v1 — 작성 시작\n\n[지침]\n- 문단: 2–3문장 단위로 호흡\n- 수위: 암시적(암시/손길/여운), 명시적 서술 금지\n- 톤: 스타일가이드 준수, 일관성 유지\n\n`; writeFileSafe(tv.manuscriptPath, header, false); generated.push(tv.manuscriptPath); } // If scene prompts missing, generate 36 prompts from outline skeleton if (createScenePrompts && !tv.hasScenePrompts && !dryRun) { const outline = readFileSafe(tv.outlinePath) || ""; const prompts = []; prompts.push(`# 권${tv.nn} 씬 프롬프트 (36)`); prompts.push("(각 씬: 갈등/정보/관계/액션/감정 중 1–2개 초점, [팔레트·광원·리듬] 메모, 암시적 처리 원칙)"); let idx = 1; // Try to parse chapter headings; fallback to 12 chapters const chapters = 12; for (let c = 1; c <= chapters; c++) { prompts.push(""); prompts.push(`## Chapter ${c}`); for (let s = 1; s <= 3; s++) { prompts.push(`- Scene ${idx++}: [초점] (…) — [팔레트] muted, [광원] soft rim, [리듬] 느-빠-느`); } } writeFileSafe(tv.scenePromptsPath, prompts.join("\n"), false); generated.push(tv.scenePromptsPath); } // Compose result summary const summary = { targetVolume: tv.vol, manuscriptPath: tv.manuscriptPath, currentChars: tv.chars, targetMinChars, needExpansion: tv.chars > 0 && tv.chars < targetMinChars, outlinePath: tv.outlinePath, hasOutline: fs.existsSync(tv.outlinePath), scenePromptsPath: tv.scenePromptsPath, hasScenePrompts: fs.existsSync(tv.scenePromptsPath), generated, nextInstruction: `이어서 진행: 권${tv.nn} 초고 확장(목표 ${targetMinChars}자+). 작성 후 resume_progress 재호출로 리포트 갱신.`, }; // Also drop a next-action file for convenience if (!dryRun) { const nextPath = path.join(coreDir, "NEXT_ACTION.json"); writeFileSafe(nextPath, JSON.stringify(summary, null, 2), true); } return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] }; } // === Phase 1 새로운 기능 핸들러 === const storyTools = new StoryTools(); if (name === "create_lorebook_entry") { const { projectPath, type, name: entryName, description, aliases, tags, relationships, attributes, notes } = args; if (!projectPath) throw new Error("projectPath 필요"); if (!type) throw new Error("type 필요"); if (!entryName) throw new Error("name 필요"); if (!description) throw new Error("description 필요"); const lorebook = new LorebookManager(projectPath); const entry = lorebook.addEntry({ type, name: entryName, description, aliases, tags, relationships, attributes, notes }); return { content: [ { type: "text", text: `Lorebook 엔트리 생성 완료:\n${JSON.stringify(entry, null, 2)}` } ] }; } if (name === "search_lorebook") { const { projectPath, query, type } = args; if (!projectPath) throw new Error("projectPath 필요"); if (!query) throw new Error("query 필요"); const lorebook = new LorebookManager(projectPath); const results = lorebook.searchEntries(query, type); return { content: [ { type: "text", text: `검색 결과 (${results.length}개):\n${JSON.stringify(results, null, 2)}` } ] }; } if (name === "generate_dialogue") { const dialogue = storyTools.generateDialogue(args); return { content: [ { type: "text", text: dialogue } ] }; } if (name === "suggest_plot_twist") { const { currentContext, twistType, seed } = args; if (!currentContext) throw new Error("currentContext 필요"); const twist = storyTools.suggestPlotTwist(currentContext, twistType, seed); return { content: [ { type: "text", text: `플롯 트위스트 제안:\n\n**타입**: ${twist.type}\n**설명**: ${twist.description}\n**구현 방안**: ${twist.implementation}` } ] }; } if (name === "track_emotion") { const { character, chapter, scene, emotions, context } = args; if (!character) throw new Error("character 필요"); if (!chapter) throw new Error("chapter 필요"); if (!scene) throw new Error("scene 필요"); if (!emotions) throw new Error("emotions 필요"); const state = storyTools.trackEmotion({ character, chapter, scene, emotions, context }); return { content: [ { type: "text", text: `감정 상태 기록:\n${JSON.stringify(state, null, 2)}` } ] }; } if (name === "analyze_emotional_arc") { const { character, startChapter, endChapter } = args; if (!character) throw new Error("character 필요"); const analysis = storyTools.analyzeEmotionalArc(character, startChapter, endChapter); return { content: [ { type: "text", text: `${character}의 감정 변화 분석:\n\n**정점**:\n${JSON.stringify(analysis.peaks, null, 2)}\n\n**저점**:\n${JSON.stringify(analysis.valleys, null, 2)}\n\n**주요 전환점**:\n${analysis.transitions.map(t => t.change).join("\n")}` } ] }; } if (name === "summarize_chapter") { const { chapterContent, maxLength } = args; if (!chapterContent) throw new Error("chapterContent 필요"); const summary = storyTools.summarizeChapter(chapterContent, maxLength); return { content: [ { type: "text", text: `챕터 요약:\n\n**요약**: ${summary.summary}\n\n**핵심 사건**:\n${summary.keyEvents.join("\n")}\n\n**등장 인물**: ${summary.characters.join(", ")}\n\n**감정 키워드**: ${summary.emotions.join(", ")}` } ] }; } if (name === "check_consistency") { const { chapters } = args; if (!chapters || !Array.isArray(chapters)) throw new Error("chapters 배열 필요"); const result = storyTools.checkConsistency(chapters); return { content: [ { type: "text", text: `일관성 검사 결과:\n\n**발견된 문제 (${result.issues.length}개)**:\n${result.issues.map(i => `- [${i.type}] 챕터 ${i.chapter}: ${i.description}`).join("\n")}\n\n**제안사항**:\n${result.suggestions.join("\n")}` } ] }; } if (name === "export_lorebook") { const { projectPath, outputPath } = args; if (!projectPath) throw new Error("projectPath 필요"); const lorebook = new LorebookManager(projectPath); cons