kangjun-story-mcp
Version:
Kangjun Story MCP: Novel writing assistant MCP server with character and RPG equipment tools
1,264 lines • 59.3 kB
JavaScript
#!/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