aimakeall-mcp 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,7 +16,7 @@ Claude Code·Codex 같은 MCP 클라이언트에서 자연어로 AImakeAll 영
16
16
  "mcpServers": {
17
17
  "aimakeall": {
18
18
  "command": "npx",
19
- "args": ["-y", "aimakeall-mcp@0.9.0"],
19
+ "args": ["-y", "aimakeall-mcp@0.9.1"],
20
20
  "env": { "AIMAKEALL_PAT": "aio_pat_..." }
21
21
  }
22
22
  }
@@ -254,7 +254,7 @@ export function registerCloudTools(server, config, api) {
254
254
  tone: z.string().optional().describe("기본 '자동 추천'"),
255
255
  sceneCount: z.number().int().min(1).max(10).optional().describe("씬 수. 생략하면 channelFingerprint 의 목표 길이로 자동 산출(지문도 없으면 3)"),
256
256
  channelFingerprint: z.object({
257
- sampleSize: z.number().optional(),
257
+ sampleSize: z.number().describe("표본 수 — 서버는 이 값이 0보다 커야 지문을 규격으로 인정한다(없으면 조용히 무시됨)"),
258
258
  durationTargetSec: z.number().nullable().optional(),
259
259
  cutIntervalSec: z.number().nullable().optional(),
260
260
  cutsPerMin: z.number().nullable().optional(),
@@ -784,9 +784,9 @@ export function registerCloudTools(server, config, api) {
784
784
  subtitleLines: z.number().nullable().optional(),
785
785
  hasBgm: z.boolean().nullable().optional(),
786
786
  }).passthrough().optional().describe("measure_channel_spec 이 반환한 fingerprint — 자막 세로 위치·BGM 유무를 참고 채널에 맞춥니다"),
787
- transitionMode: z.enum(["fade", "none"]).optional().describe("기본 fade"),
787
+ transitionMode: z.enum(["fade", "none"]).optional().describe("기본 none(하드컷). fade 는 씬이 맞대기로 붙는 배치에서 컷마다 검은 딥을 만든다 — 명시 요청 시에만 쓸 것"),
788
788
  },
789
- wrapCloudHandler(config, async ({ sceneVideos, featureKey = "commerceVideo", projectTitle = "aimakeall-mcp", aspectRatio = "9:16", ttsAudioPath = "", bgmAudioPath = "", audioDurationSec = 0, bgmVolume = null, muteVideoAudio = true, titleText = "", subtitleLines = [], emphasisKeywords = [], emphasisColor = "", emphasisSizeScale = 0, transitionMode = "fade", channelFingerprint = null }) => {
789
+ wrapCloudHandler(config, async ({ sceneVideos, featureKey = "commerceVideo", projectTitle = "aimakeall-mcp", aspectRatio = "9:16", ttsAudioPath = "", bgmAudioPath = "", audioDurationSec = 0, bgmVolume = null, muteVideoAudio = true, titleText = "", subtitleLines = [], emphasisKeywords = [], emphasisColor = "", emphasisSizeScale = 0, transitionMode = "none", channelFingerprint = null }) => {
790
790
  // 오디오 확장자 검증 + 합산 크기 예산(서버 413 사전 차단).
791
791
  if (ttsAudioPath) assertAllowedInputFile(ttsAudioPath, new Set([".mp3", ".wav"]), { kind: "TTS 오디오" });
792
792
  if (bgmAudioPath) assertAllowedInputFile(bgmAudioPath, new Set([".mp3", ".wav"]), { kind: "BGM 오디오" });
@@ -942,7 +942,7 @@ export function registerCloudTools(server, config, api) {
942
942
  storyText: z.string().optional().describe("핵심 줄거리·내용 브리프"),
943
943
  styleId: z.enum(["standard", "senior", "insight", "webnovel", "christian", "community-shorts", "viral-shorts"]).optional().describe("기본 standard. senior=시니어(쉽고 친절), insight=명언(통찰)"),
944
944
  model: z.enum(["gemini", "opus", "sonnet"]).optional().describe("기본 gemini(가성비). opus/sonnet=Claude"),
945
- targetLength: z.number().int().min(500).max(20000).optional().describe("목표 글자수, 기본 5000"),
945
+ targetLength: z.number().int().min(500).max(12000).optional().describe("목표 글자수, 기본 5000. 서버 출력 상한(8192토큰 ≈ 한국어 12,300자)을 넘으면 대본이 중간에 잘리므로 12000 이 상한이다"),
946
946
  targetLanguageLabel: z.string().optional().describe("출력 언어 라벨, 기본 '한국 (한국어)'"),
947
947
  contentFormat: z.enum(["longform", "shortform"]).optional().describe("기본 longform (standard/senior/insight는 항상 longform)"),
948
948
  webnovelSubtypeId: z.enum(["modern-webnovel", "regression-fantasy", "romance-fantasy", "martial-arts", "light-novel", "hunter-fantasy", "revenge-soda", "healing-daily", "relationship-drama", "literary-serial"]).optional().describe("styleId=webnovel일 때 장르"),
@@ -958,7 +958,7 @@ export function registerCloudTools(server, config, api) {
958
958
  endingStyle: z.string().nullable().optional(),
959
959
  emphasisCount: z.number().nullable().optional(),
960
960
  hasBgm: z.boolean().nullable().optional(),
961
- sampleSize: z.number().optional(),
961
+ sampleSize: z.number().describe("표본 수 — 서버는 이 값이 0보다 커야 지문을 규격으로 인정한다(없으면 조용히 무시됨)"),
962
962
  }).passthrough().optional().describe("measure_channel_spec 의 fingerprint — 실측 규격(길이·컷·말끝)을 대본에 강제합니다"),
963
963
  },
964
964
  wrapCloudHandler(config, async ({
@@ -1002,7 +1002,9 @@ export function registerCloudTools(server, config, api) {
1002
1002
  webnovelSubtypeId,
1003
1003
  },
1004
1004
  method: "POST",
1005
- timeoutMs: 240_000,
1005
+ // 서버 최악 예산(리서치 40s + 생성 150s×2 = 340s)보다 크게 — 역전되면
1006
+ // 클라는 실패로 끝나는데 서버는 계속 돌며 과금한다.
1007
+ timeoutMs: 420_000,
1006
1008
  });
1007
1009
  const text = String(payload?.text || "").trim();
1008
1010
  if (!text) return textResult("대본 생성 결과가 비어 있습니다. 제목/브리프를 보강해 다시 시도하세요.", { isError: true });
@@ -1023,7 +1025,7 @@ export function registerCloudTools(server, config, api) {
1023
1025
  purpose: z.enum(["investment", "owner-occupancy", "move-up", "verification"]).optional().describe("기본 investment"),
1024
1026
  budget: z.string().optional().describe("예산/가격대"),
1025
1027
  notes: z.string().optional(),
1026
- targetLength: z.number().int().min(500).max(20000).optional().describe("목표 글자수, 기본 5000"),
1028
+ targetLength: z.number().int().min(500).max(12000).optional().describe("목표 글자수, 기본 5000. 서버 출력 상한(8192토큰 ≈ 한국어 12,300자)을 넘으면 대본이 중간에 잘리므로 12000 이 상한이다"),
1027
1029
  targetLanguageLabel: z.string().optional().describe("기본 '한국 (한국어)'"),
1028
1030
  },
1029
1031
  wrapCloudHandler(config, async ({ analysisType = "apartment-report", primaryTarget = "", secondaryTarget = "", region = "", title = "", storyText = "", purpose = "investment", budget = "", notes = "", targetLength = 5000, targetLanguageLabel = "한국 (한국어)" }) => {
@@ -1053,7 +1055,9 @@ export function registerCloudTools(server, config, api) {
1053
1055
  title,
1054
1056
  },
1055
1057
  method: "POST",
1056
- timeoutMs: 240_000,
1058
+ // 서버 최악 예산(리서치 40s + 생성 150s×2 = 340s)보다 크게 — 역전되면
1059
+ // 클라는 실패로 끝나는데 서버는 계속 돌며 과금한다.
1060
+ timeoutMs: 420_000,
1057
1061
  });
1058
1062
  const text = String(payload?.text || "").trim();
1059
1063
  if (!text) return textResult("부동산 분석 대본 결과가 비어 있습니다.", { isError: true });
@@ -1074,7 +1078,7 @@ export function registerCloudTools(server, config, api) {
1074
1078
  purpose: z.enum(["startup", "investment", "site-selection", "verification"]).optional().describe("기본 startup"),
1075
1079
  budget: z.string().optional().describe("예산/운영 여력"),
1076
1080
  notes: z.string().optional(),
1077
- targetLength: z.number().int().min(500).max(20000).optional().describe("목표 글자수, 기본 5000"),
1081
+ targetLength: z.number().int().min(500).max(12000).optional().describe("목표 글자수, 기본 5000. 서버 출력 상한(8192토큰 ≈ 한국어 12,300자)을 넘으면 대본이 중간에 잘리므로 12000 이 상한이다"),
1078
1082
  targetLanguageLabel: z.string().optional().describe("기본 '한국 (한국어)'"),
1079
1083
  },
1080
1084
  wrapCloudHandler(config, async ({ analysisType = "commercial-district-report", primaryTarget = "", secondaryTarget = "", businessType = "", title = "", storyText = "", purpose = "startup", budget = "", notes = "", targetLength = 5000, targetLanguageLabel = "한국 (한국어)" }) => {
@@ -1104,7 +1108,9 @@ export function registerCloudTools(server, config, api) {
1104
1108
  title,
1105
1109
  },
1106
1110
  method: "POST",
1107
- timeoutMs: 240_000,
1111
+ // 서버 최악 예산(리서치 40s + 생성 150s×2 = 340s)보다 크게 — 역전되면
1112
+ // 클라는 실패로 끝나는데 서버는 계속 돌며 과금한다.
1113
+ timeoutMs: 420_000,
1108
1114
  });
1109
1115
  const text = String(payload?.text || "").trim();
1110
1116
  if (!text) return textResult("상권 분석 대본 결과가 비어 있습니다.", { isError: true });
@@ -1457,7 +1463,7 @@ export function registerCloudTools(server, config, api) {
1457
1463
 
1458
1464
  server.tool(
1459
1465
  "measure_channel_spec",
1460
- "벤치마크 채널의 영상 표본(유튜브 URL ≤5)을 실측해 채널 규격 지문(길이·컷간격·컷/분·라우드니스·무음비율·자막 위치·말끝 스타일)을 만듭니다. 컴패니언 앱이 다운로드·샷감지·오디오 추출을 수행하고 서버가 집계합니다. 반환된 fingerprint 를 plan_shorts_video 의 channelFingerprint 로 넘기면 대본이 그 규격을 강제합니다.",
1466
+ "벤치마크 채널의 영상 표본(유튜브 URL ≤5)을 실측해 채널 규격 지문(길이·컷간격·컷/분·라우드니스·무음비율·자막 위치·말끝 스타일)을 만듭니다. 말속도는 측정하지 않습니다. 컴패니언 앱이 다운로드·샷감지·오디오 추출을 수행하고 서버가 집계합니다. 반환된 fingerprint 를 plan_shorts_video 의 channelFingerprint 로 넘기면 대본이 그 규격을 강제합니다.",
1461
1467
  {
1462
1468
  videoUrls: z.array(z.string()).min(1).max(5).describe("표본 유튜브 URL 또는 11자 ID (조회수 상위 영상 권장)"),
1463
1469
  },
@@ -1526,6 +1532,9 @@ export function registerCloudTools(server, config, api) {
1526
1532
  notes.push(`표본 ${index + 1}: ${String(error?.message || error).slice(0, 100)}`);
1527
1533
  }
1528
1534
  }
1535
+ // 말속도(cpsTarget)는 타임코드 전사가 있어야 산출된다. 이 도구는 전사를 하지 않으므로
1536
+ // 그 축은 비워 둔다 — 재지 않은 것을 잰 척하지 않는다.
1537
+ notes.push("말속도(cpsTarget)는 측정하지 않습니다 — 타임코드 전사가 필요합니다.");
1529
1538
  if (!specs.length) {
1530
1539
  return textResult(`표본 실측에 모두 실패했습니다. 컴패니언 앱 실행 여부를 확인하세요.\n${notes.join("\n")}`, { isError: true });
1531
1540
  }
@@ -1539,7 +1548,7 @@ export function registerCloudTools(server, config, api) {
1539
1548
  }
1540
1549
  return jsonResult({
1541
1550
  fingerprint: payload.fingerprint,
1542
- next: "이 fingerprint 객체를 plan_shorts_video 의 channelFingerprint 로 그대로 넘기세요 — 대본이 실측 규격(길이·컷·말속도·말끝)을 따르게 됩니다.",
1551
+ next: "이 fingerprint 객체를 plan_shorts_video·stitch_timeline·write_script 의 channelFingerprint 로 그대로 넘기세요 — 길이·컷 리듬·말끝·자막 위치·BGM 이 이 규격을 따릅니다.",
1543
1552
  notes: notes.length ? notes : undefined,
1544
1553
  sampleCount: specs.length,
1545
1554
  });
package/lib/config.mjs CHANGED
@@ -2,7 +2,7 @@ import { homedir } from "node:os";
2
2
  import path from "node:path";
3
3
 
4
4
  // 프록시 버전 — 서버가 X-AImakeAll-MCP-Version 으로 하한을 강제(426)할 수 있다.
5
- export const MCP_PROXY_VERSION = "0.9.0";
5
+ export const MCP_PROXY_VERSION = "0.9.1";
6
6
 
7
7
  export const DEFAULT_API_BASE = "https://aimakeall.com";
8
8
  export const DEFAULT_COMPANION_URL = "http://127.0.0.1:9876";
@@ -0,0 +1,80 @@
1
+ // MCP 도구 ↔ 서버 계약 회귀.
2
+ // 감사에서 확인된 실제 결함들을 고정한다 — 전부 "테스트는 통과하는데 사용자에겐 깨져 보이던" 종류다.
3
+ //
4
+ // 이 파일은 zod·cloud-tools 를 import 하지 않는다. mcp/ 는 별도 npm 패키지라 그 의존성이
5
+ // 저장소 루트의 npm ci 에는 없고, 배포 게이트(prep.sh)는 루트에서 npm test 를 돌린다.
6
+ // 다른 mcp 테스트들도 같은 이유로 의존성 없이 작성돼 있다.
7
+ import assert from "node:assert/strict";
8
+ import { readFileSync } from "node:fs";
9
+ import test from "node:test";
10
+
11
+ const SOURCE = readFileSync(new URL("./cloud-tools.mjs", import.meta.url), "utf8");
12
+
13
+ /** 특정 api.request 호출 뒤에 붙은 timeoutMs 값을 읽는다. */
14
+ function clientBudgetFor(routePath) {
15
+ const at = SOURCE.indexOf(`"${routePath}"`);
16
+ assert.ok(at > 0, `${routePath} 호출을 찾지 못했다`);
17
+ const found = SOURCE.slice(at, at + 2600).match(/timeoutMs:\s*([0-9_]+)/);
18
+ assert.ok(found, `${routePath}: timeoutMs 를 찾지 못했다`);
19
+ return Number(found[1].replace(/_/g, ""));
20
+ }
21
+
22
+ test("stitch_timeline 전환 기본값은 none — fade 는 맞대기 배치에서 컷마다 검은 딥을 만든다", () => {
23
+ // 0.8.0 이 서버·웹 기본값만 none 으로 바꾸고 MCP 를 놓쳐 MCP 렌더에만 딥이 남아 있었다.
24
+ // MCP 는 transitionMode 를 항상 명시 전송하므로 서버 기본값이 적용되지 않는다.
25
+ assert.match(SOURCE, /transitionMode = "none"/, "MCP 핸들러 기본값이 none 이 아니다");
26
+ assert.doesNotMatch(SOURCE, /transitionMode = "fade"/, "fade 기본값이 남아 있다");
27
+ });
28
+
29
+ test("대본 도구의 targetLength 상한이 서버 출력 상한 안에 있다", () => {
30
+ // 서버는 max_tokens 8192 고정이라 한국어 약 12,300자가 한계다. 20000 을 광고하면
31
+ // 프롬프트에 도달 불가한 목표가 나가고, 잘림 감지가 없어 끊긴 대본이 정상 반환된다.
32
+ assert.doesNotMatch(SOURCE, /targetLength: z\.number\(\)\.int\(\)\.min\(500\)\.max\(20000\)/,
33
+ "도달 불가한 20000 상한이 남아 있다");
34
+ const caps = [...SOURCE.matchAll(/targetLength: z\.number\(\)\.int\(\)\.min\(500\)\.max\((\d+)\)/g)]
35
+ .map((m) => Number(m[1]));
36
+ assert.equal(caps.length, 3, `대본 도구 3종을 찾지 못했다 (${caps.length}개)`);
37
+ for (const cap of caps) assert.ok(cap <= 12000, `상한 ${cap} 이 서버 한계를 넘는다`);
38
+ });
39
+
40
+ test("지문 파라미터는 sampleSize 를 요구한다 — 없으면 서버가 조용히 무시한다", () => {
41
+ // 서버 buildFingerprintDirectives 는 sampleSize > 0 이 아니면 빈 배열을 반환한다.
42
+ // 선택 필드로 두면 부분 지문이 통과해 아무 일도 안 일어나는데 성공처럼 보인다.
43
+ assert.doesNotMatch(SOURCE, /sampleSize: z\.number\(\)\.optional\(\)/,
44
+ "sampleSize 가 아직 선택 필드다");
45
+ const required = [...SOURCE.matchAll(/sampleSize: z\.number\(\)\.describe\(/g)].length;
46
+ assert.ok(required >= 2, `sampleSize 필수 선언이 부족하다 (${required}곳)`);
47
+ });
48
+
49
+ test("measure_channel_spec 은 재지 않는 축(말속도)을 약속하지 않는다", () => {
50
+ // cpsTarget 은 타임코드 전사가 있어야 산출되는데 이 도구는 전사를 하지 않는다.
51
+ const at = SOURCE.indexOf('"measure_channel_spec"');
52
+ assert.ok(at > 0);
53
+ const block = SOURCE.slice(at, at + 1200);
54
+ assert.match(block, /말속도는 측정하지 않습니다/, "재지 않는 축을 약속하고 있다");
55
+ });
56
+
57
+ test("대본 도구의 클라이언트 예산이 서버 최악 예산보다 크다", () => {
58
+ // 역전되면 클라는 실패로 끝나는데 서버는 계속 돌며 과금한다 — 회수 불가능한 지출.
59
+ // 서버 최악: 리서치 8초 + 생성 150초 × 2회 ≈ 310초.
60
+ for (const route of [
61
+ "/api/tracker/ai/script",
62
+ "/api/tracker/ai/real-estate-report",
63
+ "/api/tracker/ai/market-analysis",
64
+ ]) {
65
+ const budget = clientBudgetFor(route);
66
+ assert.ok(budget >= 400_000, `${route}: 클라이언트 예산 ${budget}ms 가 서버 최악보다 작다`);
67
+ }
68
+ });
69
+
70
+ test("이 테스트 파일은 mcp 전용 의존성을 import 하지 않는다", () => {
71
+ // 배포 게이트는 저장소 루트에서 npm test 를 돌리는데 zod 는 mcp/ 에만 있다.
72
+ // (실제로 이 규칙을 어겨 배포가 한 번 막혔다.)
73
+ const self = readFileSync(new URL("./tool-contract.test.mjs", import.meta.url), "utf8");
74
+ const importLines = self.split("\n").filter((line) => /^\s*import\s/.test(line));
75
+ for (const line of importLines) {
76
+ assert.doesNotMatch(line, /["']zod["']/, `zod 를 import 하면 루트 npm test 에서 깨진다: ${line.trim()}`);
77
+ assert.doesNotMatch(line, /cloud-tools\.mjs/, `cloud-tools 는 zod 를 import 하므로 같이 깨진다: ${line.trim()}`);
78
+ }
79
+ assert.ok(importLines.length >= 3, "import 문을 찾지 못했다 — 검사가 무력화됐다");
80
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aimakeall-mcp",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "AImakeAll MCP 서버 — Claude Code/Codex에서 자연어로 영상 기획·생성·렌더·퍼블리시 (렌더는 로컬 컴패니언)",
5
5
  "type": "module",
6
6
  "bin": {