aimakeall-mcp 0.14.1 → 0.14.2

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.
@@ -23,7 +23,8 @@ import {
23
23
  import { audioDurationSecFromFile } from "./audio-duration.mjs";
24
24
  import { canonicalYoutubeWatchUrl } from "./payload-guard.mjs";
25
25
  import { createUsageEventId } from "./usage-event.mjs";
26
- import { fetchGenerationJob, GENERATION_JOB_PATH, submitGenerationJob } from "./generation-jobs.mjs";
26
+ import { fetchGenerationJob, GENERATION_JOB_PATH, submitGenerationJob, requestGenerationJobAction } from "./generation-jobs.mjs";
27
+ import { durableSpeechToolSchema, submitDurableSpeech, durableSpeechResult } from "./durable-speech.mjs";
27
28
  import { loudnessDbfsFromWav, silenceRatioFromWav } from "./wav-dsp.mjs";
28
29
  import { trimWavToSeconds } from "./wav-trim.mjs";
29
30
  import { registerComposerTools } from "./composer-tools.mjs";
@@ -203,6 +204,7 @@ export function registerCloudTools(server, config, api) {
203
204
  registerComposerTools(server, config, api);
204
205
  registerReviewTools(server, config, api, { wrap: handler => wrapCloudHandler(config, handler) });
205
206
  async function completedGenerationResult(job, includePreview) {
207
+ if (job.kind === "tts") return jsonResult(durableSpeechResult(job, config));
206
208
  const payload = job.result || {};
207
209
  const resultBody = { generationJobId: job.id, status: job.status, kind: job.kind,
208
210
  model: payload.model, taskId: payload.taskId, providerTasks: job.providerTasks };
@@ -242,21 +244,49 @@ export function registerCloudTools(server, config, api) {
242
244
 
243
245
  server.tool(
244
246
  "get_generation_job",
245
- "generate_scene_image/video가 반환한 generationJobId의 상태·결과를 조회합니다. running이면 잠시 뒤 다시 조회하고, completed의 imageUrl/videoUrl로 다음 단계를 진행하세요. MCP를 재시작해도 같은 ID로 회수할 수 있습니다.",
247
+ "이미지·영상·start_speech_generation의 generationJobId 상태·결과를 조회합니다. running이면 같은 ID를 다시 조회하세요. TTS 완료는 서버 소유 오디오 링크를 반환하며 다운로드·재합성하지 않습니다. MCP 재시작 뒤에도 같은 ID를 사용하세요.",
246
248
  { generationJobId: z.string(), includePreview: z.boolean().optional().describe("이미지/대표 프레임 미리보기 포함. 미지정 시 생성 요청의 returnImage/returnFrames 설정 유지") },
247
249
  wrapCloudHandler(config, async ({ generationJobId, includePreview }) => {
248
250
  const job = await fetchGenerationJob(api, generationJobId);
249
251
  if (job.status === "completed") return completedGenerationResult(job, includePreview ?? job.includePreview ?? true);
250
252
  return textResult(JSON.stringify({ generationJobId: job.id, kind: job.kind, status: job.status,
251
- providerTasks: job.providerTasks, error: job.error,
252
- guidance: job.status === "running" ? "생성 중입니다. 5~10초 후 이 ID를 다시 조회하세요. 새로 생성하지 마세요." : "비용 원장과 providerTasks를 먼저 확인하세요. 실패·중단 작업을 자동 재생성하지 마세요." }, null, 2),
253
+ providerTasks: job.providerTasks, error: job.error, workflow: job.workflow,
254
+ ...(job.status === "cancelled" ? { result: job.result } : {}),
255
+ guidance: job.status === "running" ? "생성 중입니다. 5~10초 후 이 ID를 다시 조회하세요. 새로 생성하지 마세요."
256
+ : ["cancel_requested", "cancelled"].includes(job.status) ? "취소 의도가 저장되어 있습니다. 공급자 중단·환불을 의미하지 않습니다. 이미 접수된 작업과 비용 원장을 확인하세요."
257
+ : job.workflow?.canResume ? "resume_generation_job으로 이미 접수된 작업의 상태만 복구할 수 있습니다. 새 유료 생성을 제출하지 마세요."
258
+ : "비용 원장과 providerTasks를 먼저 확인하세요. 실패·중단 작업을 자동 재생성하지 마세요." }, null, 2),
253
259
  { isError: job.status === "failed" || job.status === "interrupted" });
254
260
  }),
255
261
  );
256
262
 
263
+ server.tool(
264
+ "start_speech_generation",
265
+ "지정한 ElevenLabs/Typecast/Supertone 한 곳에 비동기 내레이션 작업을 접수합니다. 공유 실행 저장소가 필요합니다. requestId를 유지하고 get_generation_job으로 조회하세요. 대체 공급자·자동 재합성·기존 바이너리 TTS로 전환하지 않으며, 완료 오디오는 서버 소유 링크로 반환됩니다.",
266
+ durableSpeechToolSchema,
267
+ wrapCloudHandler(config, async input => {
268
+ const result = await submitDurableSpeech(api, input);
269
+ return textResult(JSON.stringify(result, null, 2), { isError: ["unavailable", "rejected"].includes(result.status) });
270
+ }),
271
+ );
272
+
273
+ server.tool(
274
+ "resume_generation_job",
275
+ "내 계정의 기존 생성 작업에서 접수된 공급자 작업 상태·결과를 복구합니다. 원래 유료 생성은 다시 제출하지 않습니다. get_generation_job의 workflow.canResume이 true일 때 사용하세요.",
276
+ { generationJobId: z.string() },
277
+ wrapCloudHandler(config, async ({ generationJobId }) => jsonResult(await requestGenerationJobAction(api, generationJobId, "resume"))),
278
+ );
279
+
280
+ server.tool(
281
+ "cancel_generation_job",
282
+ "내 계정의 기존 생성 작업에 취소 의도를 저장하고 가능한 로컬 대기를 중단합니다. 공급자 작업 중단·환불을 보장하지 않으며 접수된 작업과 비용 기록은 유지합니다.",
283
+ { generationJobId: z.string() },
284
+ wrapCloudHandler(config, async ({ generationJobId }) => jsonResult(await requestGenerationJobAction(api, generationJobId, "cancel"))),
285
+ );
286
+
257
287
  server.tool(
258
288
  "list_generation_jobs",
259
- "내 계정의 최근 이미지·영상 생성 작업 ID와 상태를 조회합니다. 연결이 끊기거나 MCP를 재시작했을 때 결과를 복구하고 중복 생성을 피하는 데 사용하세요.",
289
+ "내 계정의 최근 이미지·영상·음성 생성 작업 ID와 상태를 조회합니다. 연결이 끊기거나 MCP를 재시작했을 때 결과를 복구하고 중복 생성을 피하는 데 사용하세요.",
260
290
  {},
261
291
  wrapCloudHandler(config, async () => jsonResult(await api.request(GENERATION_JOB_PATH, { timeoutMs: 30_000 }))),
262
292
  );
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.14.1";
5
+ export const MCP_PROXY_VERSION = "0.14.2";
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,94 @@
1
+ import { z } from "zod";
2
+ import { DEFAULT_API_BASE } from "./config.mjs";
3
+ import { GENERATION_JOB_PATH } from "./generation-jobs.mjs";
4
+
5
+ const ID = /^[a-zA-Z0-9_-]{16,80}$/u, PROVIDERS = ["elevenlabs", "typecast", "supertone"];
6
+ const MODELS = Object.freeze({ elevenlabs: "elevenlabs-multilingual-v2", typecast: "typecast-narration", supertone: "supertone-narration" });
7
+ const common = ["voice", "voiceId", "modelId", "fileName", "speed"];
8
+ const perProvider = Object.freeze({ elevenlabs: ["languageCode", "similarityBoost", "speakerBoost", "stability", "style"],
9
+ typecast: ["language", "emotion", "pitch", "smartEmotion", "volume"], supertone: ["language", "lang", "style"] });
10
+ const invalid = () => new Error("Invalid durable speech request or result.");
11
+ const short = max => z.string().max(max);
12
+ const optionsSchema = z.object({ voice: short(320).optional(), voiceId: short(160).optional(), modelId: short(100).optional(), fileName: short(240).optional(),
13
+ languageCode: short(20).optional(), language: short(20).optional(), lang: short(20).optional(), emotion: short(160).optional(),
14
+ speed: z.number().min(0.5).max(2).optional(), similarityBoost: z.number().min(0).max(1).optional(), stability: z.number().min(0).max(1).optional(),
15
+ style: z.union([z.number().min(0).max(1), short(160)]).optional(), speakerBoost: z.boolean().optional(), smartEmotion: z.boolean().optional(),
16
+ pitch: z.number().int().min(-12).max(12).optional(), volume: z.number().int().min(0).max(200).optional(),
17
+ }).strict();
18
+ export const durableSpeechToolSchema = Object.freeze({
19
+ requestId: z.string().regex(ID).describe("새 작업의 고유 ID. 응답이 끊겨도 이 ID를 유지하고 먼저 상태를 조회하세요."),
20
+ provider: z.enum(PROVIDERS).describe("지정 공급자만 사용합니다. 자동 선택·대체 공급자 재합성 없음"),
21
+ text: z.string().min(1).max(10000).describe("전체 내레이션. 최대 5,000자, Supertone은 300자"),
22
+ options: optionsSchema.optional().describe("해당 공급자의 보이스·모델·언어·속도 옵션. 비용 이벤트/실행 상태는 전달하지 마세요."),
23
+ });
24
+ function capture(value) {
25
+ if (!value || typeof value !== "object" || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) throw invalid();
26
+ const props = Object.getOwnPropertyDescriptors(value);
27
+ if (Reflect.ownKeys(props).some(key => !["requestId", "provider", "text", "options"].includes(key) || !Object.hasOwn(props[key], "value"))) throw invalid();
28
+ const options = props.options?.value;
29
+ if (options !== undefined && (!options || typeof options !== "object" || ![Object.prototype, null].includes(Object.getPrototypeOf(options))
30
+ || Reflect.ownKeys(Object.getOwnPropertyDescriptors(options)).some(key => typeof key !== "string" || !Object.hasOwn(Object.getOwnPropertyDescriptor(options, key), "value")))) throw invalid();
31
+ const parsed = z.object(durableSpeechToolSchema).strict().parse(value), { provider } = parsed;
32
+ if (!parsed.text.isWellFormed() || /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(parsed.text)
33
+ || !parsed.text.trim() || Array.from(parsed.text).length > (provider === "supertone" ? 300 : 5000)) throw invalid();
34
+ const rawOptions = parsed.options || {};
35
+ if (Object.keys(rawOptions).some(key => ![...common, ...perProvider[provider]].includes(key))) throw invalid();
36
+ for (const [key, value] of Object.entries(rawOptions)) {
37
+ if (typeof value === "string" && (!value.isWellFormed() || /[\u0000-\u001f\u007f]/u.test(value))) throw invalid();
38
+ if (["voiceId", "modelId"].includes(key) && value && !/^[A-Za-z0-9_.:-]+$/u.test(value)) throw invalid();
39
+ }
40
+ if (provider === "elevenlabs" && (rawOptions.speed !== undefined && (rawOptions.speed < 0.7 || rawOptions.speed > 1.2)
41
+ || rawOptions.style !== undefined && typeof rawOptions.style !== "number")
42
+ || provider === "supertone" && rawOptions.style !== undefined && typeof rawOptions.style !== "string") throw invalid();
43
+ return { requestId: parsed.requestId, input: { provider, text: parsed.text.trim(), ...rawOptions, allowProviderFallback: false } };
44
+ }
45
+
46
+ /** One JSON submit only. Neither errors nor a missing shared backend may invoke
47
+ * the legacy binary tool, switch provider, or create a replacement request ID. */
48
+ export async function submitDurableSpeech(api, value) {
49
+ const { requestId, input } = capture(value), base = { generationJobId: requestId, kind: "tts", paidReplayAllowed: false, nextTool: "get_generation_job" };
50
+ try {
51
+ const response = await api.request(GENERATION_JOB_PATH, { method: "POST", timeoutMs: 30000, body: { id: requestId, kind: "tts", input, includePreview: false } });
52
+ if (response?.job?.id !== requestId || response.job.kind !== "tts" || !["pending", "running", "completed", "failed", "interrupted", "cancel_requested", "cancelled"].includes(response.job.status)) throw invalid();
53
+ return { ...base, status: response.job.status, existing: response.existing === true,
54
+ guidance: "이 ID를 get_generation_job으로 조회하세요. 완료 전 새 TTS 작업이나 바이너리 합성 도구로 다시 요청하지 마세요." };
55
+ } catch (error) {
56
+ const code = typeof error?.errorCode === "string" && /^[A-Z][A-Z0-9_]{0,99}$/u.test(error.errorCode) ? error.errorCode : "SPEECH_SUBMISSION_UNCONFIRMED";
57
+ if (code === "MACHINE_TTS_DURABLE_WORKLOAD_REQUIRED") return { ...base, status: "unavailable", code,
58
+ guidance: "서버의 공유 TTS 실행 저장소가 준비되지 않았습니다. 자동 재시도나 기존 바이너리 합성으로 전환하지 마세요." };
59
+ return { ...base, status: Number(error?.status) >= 400 && Number(error?.status) < 500 ? "rejected" : "unknown", code,
60
+ guidance: "이 작업 ID로 먼저 상태를 조회하세요. 접수 결과가 불명확하면 새 ID 제출·공급자 교체·바이너리 재합성을 하지 마세요." };
61
+ }
62
+ }
63
+
64
+ /** Pure URL projection only: no fetch and no PAT-bearing request to an asset.
65
+ * Only the configured server's canonical protected filename and its asset_sig
66
+ * may leave this helper; provider URLs, credentials and unrelated query data do
67
+ * not become clickable output. */
68
+ export function durableSpeechResult(job, { apiBase = DEFAULT_API_BASE } = {}) {
69
+ const payload = job?.result;
70
+ if (!ID.test(job?.id || "") || job.kind !== "tts" || job.status !== "completed" || !PROVIDERS.includes(payload?.provider)
71
+ || payload.primaryProvider !== payload.provider || !/^user-[a-f0-9]{24}-[A-Za-z0-9._-]+$/u.test(payload.filename || "")
72
+ || payload.filename.includes("..") || payload.filename.length > 512 || !["audio/mpeg", "audio/wav", "audio/ogg", "audio/mp4"].includes(payload.contentType)) throw invalid();
73
+ const base = new URL(apiBase), rawUrl = payload.assetUrl;
74
+ if (!["https:", "http:"].includes(base.protocol) || base.username || base.password || base.search || base.hash
75
+ || typeof rawUrl !== "string" || rawUrl.length > 8192 || /[\s\\\u0000-\u001f\u007f]/u.test(rawUrl) || rawUrl.startsWith("//")) throw invalid();
76
+ const url = new URL(rawUrl, base.origin);
77
+ if (url.origin !== base.origin || url.username || url.password || url.hash || url.pathname !== `/api/tracker/extension/assets/${encodeURIComponent(payload.filename)}`
78
+ || ![url.href, `${url.pathname}${url.search}`].includes(rawUrl)
79
+ || [...url.searchParams.keys()].some(key => key !== "asset_sig") || url.searchParams.getAll("asset_sig").length > 1
80
+ || url.searchParams.has("asset_sig") && !/^[A-Za-z0-9_-]{43}$/u.test(url.searchParams.get("asset_sig"))) throw invalid();
81
+ const cost = payload.accountCostEvents?.[0];
82
+ if (payload.hasAccountCostEvents !== true || !Array.isArray(payload.accountCostEvents) || payload.accountCostEvents.length !== 1
83
+ || !cost || cost.provider !== payload.provider || cost.model !== MODELS[payload.provider] || !/^tts-job-v1:[a-f0-9]{64}$/u.test(cost.accountCostEventId || "")
84
+ || payload.accountCostEventId !== cost.accountCostEventId || payload.accountCostRecorded !== cost.accountCostRecorded
85
+ || payload.accountCostSuppressedByReset !== cost.accountCostSuppressedByReset
86
+ || typeof cost.costUsd !== "number" || !Number.isFinite(cost.costUsd) || cost.costUsd < 0 || cost.costUsd > 10000
87
+ || !Number.isSafeInteger(cost.createdAt) || cost.createdAt < 1
88
+ || !(cost.accountCostRecorded === true && cost.accountCostSuppressedByReset !== true || cost.accountCostRecorded === false && cost.accountCostSuppressedByReset === true)) throw invalid();
89
+ const receipt = { accountCostEventId: cost.accountCostEventId, model: cost.model, provider: cost.provider, createdAt: cost.createdAt,
90
+ costUsd: cost.costUsd, accountCostRecorded: cost.accountCostRecorded, ...(cost.accountCostSuppressedByReset === true ? { accountCostSuppressedByReset: true } : {}) };
91
+ return { generationJobId: job.id, status: "completed", kind: "tts", provider: payload.provider, filename: payload.filename, contentType: payload.contentType,
92
+ serverUrl: base.origin, assetPath: `${url.pathname}${url.search}`, assetUrl: url.href, ...receipt, hasAccountCostEvents: true, accountCostEvents: [receipt],
93
+ guidance: "오디오가 서버의 계정 소유 자산으로 저장되었습니다. 이 링크를 사용하세요. 이 도구는 오디오를 다운로드하거나 PAT를 자산 요청에 전달하지 않습니다." };
94
+ }
@@ -37,3 +37,18 @@ export async function fetchGenerationJob(api, generationJobId) {
37
37
  if (!response?.job || response.job.id !== id) throw new Error("생성 작업 상태 응답이 올바르지 않습니다.");
38
38
  return response.job;
39
39
  }
40
+
41
+ // Both actions operate on the authenticated owner's existing job only. Resume
42
+ // observes accepted provider receipts; it never resubmits the original request.
43
+ export async function requestGenerationJobAction(api, generationJobId, action) {
44
+ const id = assertGenerationJobId(generationJobId);
45
+ if (!["resume", "cancel"].includes(action)) throw new Error("지원하지 않는 생성 작업 동작입니다.");
46
+ const response = await api.request(`${GENERATION_JOB_PATH}/${encodeURIComponent(id)}/${action}`, { method: "POST", timeoutMs: 30_000 });
47
+ if (!response?.job || response.job.id !== id || response.job.workflow?.paidReplayAllowed !== false) throw new Error("생성 작업 상태 응답이 올바르지 않습니다.");
48
+ return { generationJobId: id, kind: response.job.kind, status: response.job.status,
49
+ workflow: response.job.workflow, providerTasks: response.job.providerTasks, existing: response.existing === true,
50
+ nextTool: "get_generation_job",
51
+ guidance: action === "resume"
52
+ ? "접수된 공급자 작업의 상태·결과만 복구합니다. 유료 생성을 다시 제출하지 않습니다. get_generation_job으로 이 ID를 조회하세요."
53
+ : "취소 의도를 저장했습니다. 공급자 작업 중단·비용 환불은 보장하지 않습니다. 이미 접수된 작업과 비용 기록은 유지되므로 get_generation_job으로 확인하세요." };
54
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aimakeall-mcp",
3
- "version": "0.14.1",
3
+ "version": "0.14.2",
4
4
  "description": "AImakeAll MCP 서버 — Claude Code/Codex에서 자연어로 영상 기획·생성·렌더·퍼블리시 (렌더는 로컬 컴패니언)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,6 +23,9 @@
23
23
  "@modelcontextprotocol/sdk": "^1.30.0",
24
24
  "zod": "^3.25.1"
25
25
  },
26
- "bundleDependencies": ["@modelcontextprotocol/sdk", "zod"],
26
+ "bundleDependencies": [
27
+ "@modelcontextprotocol/sdk",
28
+ "zod"
29
+ ],
27
30
  "license": "UNLICENSED"
28
31
  }