aimakeall-mcp 0.14.0 → 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.
package/README.md CHANGED
@@ -42,6 +42,29 @@ npx -y aimakeall-mcp@0.14.0 --version
42
42
  npx -y aimakeall-mcp@0.14.0 --update-status
43
43
  ```
44
44
 
45
+ ### 연결된 세션의 버전 조회 (런타임 0.14.1부터)
46
+
47
+ 클라이언트의 `/mcp` 메뉴가 버전 필드를 표시하지 않아도 AI에게 **“mcp_status로 지금 연결된 MCP 버전과 업데이트 상태를 확인해 줘”**라고 요청할 수 있습니다. 이 도구는 PAT·로그인·유료 API·렌더 컴패니언 없이 동작합니다.
48
+
49
+ - `mcp_status({})`: 현재 실행 버전과 공식 승인 최신 버전을 비교합니다. 승인 API만 최대 2.5초 조회하며 다운로드·설치·캐시 쓰기·프로세스 종료/재연결은 하지 않습니다.
50
+ - `mcp_status({checkForUpdates:false})`: 외부 네트워크 없이 로컬 상태만 읽습니다.
51
+ - 리소스 `aimakeall://mcp/status`: 위 로컬 전용 상태를 JSON으로 제공합니다. 리소스 검색에도 표시되며, 최신 승인 버전 확인에는 `mcp_status`를 사용합니다.
52
+ - `runtimeVersion`: **이 연결에서 이미 실행 중인 버전**입니다. 디스크 패키지가 갱신돼도 실행 중인 버전은 바뀌지 않습니다.
53
+ - `runtimePackageVersionOnDisk`: 실행된 런타임 위치의 현재 패키지 버전입니다. 자동 업데이트 캐시에서 실행 중이라면 캐시 패키지를 뜻하며, 전역 설치된 부트스트랩 버전과는 다릅니다.
54
+ - `latestApprovedVersion` / `updateAvailable` / `reconnectRequired`: 승인된 새 버전과 재연결 필요성을 구분합니다. 조회 실패나 생략은 `null`로 표시하므로 이를 “최신”으로 해석하지 마세요. `ahead-of-approved`도 공식 최신이라는 뜻이 아닙니다.
55
+ - `autoUpdateEnabled`: MCP 시작 시 자동 업데이트 설정입니다. 꺼져 있어도 상태 조회는 가능하지만 재연결만으로 새 버전을 설치하지 않습니다.
56
+
57
+ CLI의 `--version`은 **그 명령으로 실행한 실행기**의 버전이며 기존 세션의 버전을 증명하지 않습니다. `--update-status`도 기존 연결을 갱신하지 않습니다. 현재 연결에 `mcp_status`가 없으면 구버전이므로, 새 버전 게시와 운영 승인 후 진행 중인 작업이 끝난 유휴 상태에서 MCP를 다시 연결하세요. 기존 `@0.14.0` 부트스트랩 설정은 유지합니다.
58
+
59
+ ### 새 버전 재연결 안내
60
+
61
+ 일반 MCP 도구를 호출할 때 공식 승인 버전을 확인하고, 새 버전이나 런타임 디스크 변경이 감지되면 도구 응답에 **MCP 업데이트 안내**를 덧붙입니다. 원래 성공/오류/이미지/구조화 결과는 그대로 보존합니다. 클라이언트 AI에는 이 안내를 사용자에게 전달하도록 초기화 지침을 제공합니다. 특정 클라이언트의 `/mcp` 화면이나 토스트 UI를 강제로 변경하는 기능은 아닙니다.
62
+
63
+ - 첫 도구 호출 이후 최대 5분에 한 번, 다음 도구 호출 때만 조회합니다. 작업 없이 백그라운드 타이머를 돌리거나 호출할 때마다 서버에 요청하지 않습니다.
64
+ - 같은 대상 버전은 연결당 한 번만 안내하며, 동시 호출도 중복 조회/안내하지 않습니다. 조회 실패나 확인되지 않은 버전은 업데이트 안내를 만들지 않습니다.
65
+ - 안내를 받으면 **진행 중인 제작·렌더를 마친 뒤** `/mcp` → `aimakeall` → `Reconnect`로 재연결합니다. 다른 클라이언트에서는 해당 MCP 서버를 재연결합니다. 자동 업데이트를 껐다면 설정도 다시 활성화해야 합니다.
66
+ - 구버전 프로세스에는 새 알림 기능이 소급 적용되지 않습니다. 이 기능이 포함된 런타임 0.14.1을 최초 한 번 받은 이후의 업데이트부터 자동 안내가 가능합니다. 알림 전달과 실제 업데이트는 별개이며, 설치는 여전히 다음 시작/재연결 시에만 수행합니다.
67
+
45
68
  ## 환경변수
46
69
 
47
70
  | 변수 | 기본값 | 설명 |
@@ -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.0";
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
+ }
@@ -0,0 +1,127 @@
1
+ // Read-only diagnostics. Never select/install a runtime or read application credentials.
2
+ import { readFile } from "node:fs/promises";
3
+
4
+ import { compareVersions, RELEASE_URL, requestBytes, validateManifest } from "./auto-update.mjs";
5
+
6
+ const MAX_MANIFEST_BYTES = 16 * 1024;
7
+ const MANIFEST_TIMEOUT_MS = 2_500;
8
+
9
+ function validVersion(value) {
10
+ if (typeof value !== "string") return null;
11
+ try {
12
+ compareVersions(value, value);
13
+ return value;
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
18
+
19
+ async function readRuntimePackageVersion() {
20
+ const pkg = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
21
+ return pkg?.name === "aimakeall-mcp" ? validVersion(pkg.version) : null;
22
+ }
23
+
24
+ function timestamp(now) {
25
+ try {
26
+ const value = now();
27
+ return typeof value === "number" && Number.isFinite(value) ? new Date(value).toISOString() : null;
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ function guidanceFor(status, diskChanged, autoUpdateEnabled) {
34
+ const messages = {
35
+ "up-to-date": "실행 중인 MCP 버전이 공식 승인 버전과 같습니다.",
36
+ "update-available": "공식 승인된 더 높은 MCP 버전이 있습니다.",
37
+ "ahead-of-approved": "실행 중인 MCP 버전이 공식 승인 버전보다 높습니다. 최신 승인 버전이라고 단정할 수 없으며, 재연결 시 승인 버전으로 돌아갈 수 있습니다.",
38
+ unavailable: "공식 승인 버전을 확인하지 못했습니다. 현재 실행 버전만 확인할 수 있으며 최신 여부는 알 수 없습니다.",
39
+ "not-checked": "공식 승인 버전은 조회하지 않았습니다. 현재 실행 버전만 확인할 수 있으며 최신 여부는 알 수 없습니다.",
40
+ };
41
+ const parts = [messages[status]];
42
+ if (diskChanged) {
43
+ parts.push("실행 중인 버전과 런타임 패키지의 디스크 버전이 다릅니다.");
44
+ }
45
+ if (!autoUpdateEnabled) {
46
+ parts.push("자동 업데이트가 꺼져 있습니다. 업데이트가 필요하면 AIMAKEALL_MCP_AUTO_UPDATE=0 설정을 해제한 뒤 작업 종료 후 재연결하거나, 승인 버전을 별도로 설치하세요.");
47
+ }
48
+ if (status === "update-available" || diskChanged) {
49
+ parts.push("진행 중인 작업을 끝낸 뒤 유휴 상태에서 /mcp → aimakeall → Reconnect를 선택하세요(다른 클라이언트는 MCP 재연결).");
50
+ }
51
+ parts.push("이 상태 조회는 설치, 실행 버전 변경 또는 재시작을 하지 않습니다.");
52
+ return parts.join(" ");
53
+ }
54
+
55
+ /**
56
+ * Capture the running process identity once. The adjacent package can change
57
+ * after startup, but its on-disk version must never replace runtimeVersion.
58
+ * readInstalledVersion returns a version string (or null), not a global npm path.
59
+ * checkedAt is the completed manifest-check time, or null when not requested.
60
+ */
61
+ export function createMcpStatusReader({
62
+ runtimeVersion,
63
+ readInstalledVersion = readRuntimePackageVersion,
64
+ download = requestBytes,
65
+ now = Date.now,
66
+ env = process.env,
67
+ nodeVersion = process.versions.node,
68
+ pid = process.pid,
69
+ platform = process.platform,
70
+ } = {}) {
71
+ const version = validVersion(runtimeVersion);
72
+ if (!version) throw new Error("Invalid MCP runtime version");
73
+ const startedAt = timestamp(now);
74
+ const autoUpdateEnabled = env.AIMAKEALL_MCP_AUTO_UPDATE !== "0";
75
+ const processIdentity = Object.freeze({ pid, startedAt, nodeVersion, platform });
76
+ const nodeMajor = Number(nodeVersion.split(".")[0]);
77
+
78
+ return async function readMcpStatus({ checkForUpdates = true } = {}) {
79
+ let runtimePackageVersionOnDisk = null;
80
+ try {
81
+ runtimePackageVersionOnDisk = validVersion(await readInstalledVersion());
82
+ } catch {
83
+ // The path, credentials, or arbitrary exception text must never reach MCP output.
84
+ }
85
+ const diskChanged = runtimePackageVersionOnDisk !== null && runtimePackageVersionOnDisk !== version;
86
+ let latestApprovedVersion = null;
87
+ let updateStatus = "not-checked";
88
+ let updateAvailable = null;
89
+ let reconnectRequired = diskChanged ? true : null;
90
+ let checkedAt = null;
91
+
92
+ if (checkForUpdates) {
93
+ try {
94
+ const bytes = await download(RELEASE_URL, {
95
+ maxBytes: MAX_MANIFEST_BYTES,
96
+ timeoutMs: MANIFEST_TIMEOUT_MS,
97
+ });
98
+ if (!Buffer.isBuffer(bytes) || bytes.length > MAX_MANIFEST_BYTES) throw new Error("Invalid MCP release metadata");
99
+ const approved = validateManifest(JSON.parse(bytes.toString("utf8")), nodeMajor);
100
+ latestApprovedVersion = approved.version;
101
+ const comparison = compareVersions(approved.version, version);
102
+ updateStatus = comparison > 0 ? "update-available" : comparison < 0 ? "ahead-of-approved" : "up-to-date";
103
+ updateAvailable = comparison > 0;
104
+ // A higher running version may be rolled back on reconnect; do not
105
+ // promise that reconnect is unnecessary in that case.
106
+ reconnectRequired = diskChanged || updateAvailable ? true : comparison < 0 ? null : false;
107
+ } catch {
108
+ updateStatus = "unavailable";
109
+ }
110
+ checkedAt = timestamp(now);
111
+ }
112
+
113
+ return {
114
+ schemaVersion: 1,
115
+ runtimeVersion: version,
116
+ runtimePackageVersionOnDisk,
117
+ latestApprovedVersion,
118
+ updateStatus,
119
+ updateAvailable,
120
+ reconnectRequired,
121
+ autoUpdateEnabled,
122
+ checkedAt,
123
+ process: { ...processIdentity },
124
+ guidance: guidanceFor(updateStatus, diskChanged, autoUpdateEnabled),
125
+ };
126
+ };
127
+ }
@@ -0,0 +1,29 @@
1
+ import { z } from "zod";
2
+ import { createMcpStatusReader } from "./mcp-status.mjs";
3
+
4
+ export const MCP_STATUS_RESOURCE_URI = "aimakeall://mcp/status";
5
+ export const MCP_STATUS_INSTRUCTIONS = "MCP 버전이나 업데이트 상태를 물으면 mcp_status를 호출하세요. runtimeVersion은 이 연결에서 실제 실행 중인 버전입니다. 설치 파일 버전, npm 태그, MoviePy 버전으로 대신 답하지 마세요. 이 도구는 읽기 전용이며 업데이트나 재연결을 실행하지 않습니다. 도구 결과에 MCP 업데이트 안내가 포함되면 사용자에게 전달하세요. 진행 중인 제작·렌더는 그대로 마치고, 유휴 상태에서만 사용자의 요청에 따라 재연결하세요.";
6
+
7
+ export function registerMcpStatusTools(server, config, {
8
+ readStatus = createMcpStatusReader({ runtimeVersion: config.version }),
9
+ } = {}) {
10
+ server.registerTool("mcp_status", {
11
+ title: "AImakeAll MCP 버전 및 업데이트 상태",
12
+ description: "현재 연결된 MCP 프로세스의 실제 버전, 디스크의 런타임 패키지 버전, 공식 승인 최신 버전, 자동 업데이트 설정, 업데이트·재연결 필요 여부를 확인합니다. 버전 질문에는 이 도구를 사용하세요. PAT/유료 API/컴패니언 없이 동작하며 설치·재시작·파일 변경을 하지 않습니다. 확인 실패는 최신으로 단정하지 않고 unknown(null)으로 반환합니다.",
13
+ inputSchema: {
14
+ checkForUpdates: z.boolean().default(true).describe("공식 승인 버전 조회 여부. false이면 외부 네트워크 없이 현재 실행 버전만 확인합니다."),
15
+ },
16
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
17
+ }, async ({ checkForUpdates }) => {
18
+ const status = await readStatus({ checkForUpdates });
19
+ return { content: [{ type: "text", text: JSON.stringify(status, null, 2) }], structuredContent: status };
20
+ });
21
+
22
+ server.registerResource("aimakeall-mcp-status", MCP_STATUS_RESOURCE_URI, {
23
+ title: "AImakeAll MCP 실행 버전",
24
+ description: "이 연결의 실행 버전과 로컬 상태를 네트워크 없이 읽습니다. 공식 승인 최신 버전과 비교하려면 mcp_status 도구를 호출하세요.",
25
+ mimeType: "application/json",
26
+ }, async (uri) => ({
27
+ contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(await readStatus({ checkForUpdates: false }), null, 2) }],
28
+ }));
29
+ }
@@ -0,0 +1,100 @@
1
+ // Opportunistic read-only notices: no polling timer, installer, or tool replay.
2
+ import { compareVersions } from "./auto-update.mjs";
3
+
4
+ const DEFAULT_INTERVAL_MS = 5 * 60_000;
5
+
6
+ function validVersion(value) {
7
+ if (typeof value !== "string") return null;
8
+ try { compareVersions(value, value); return value; }
9
+ catch { return null; }
10
+ }
11
+
12
+ function updateTarget(status) {
13
+ const runtimeVersion = validVersion(status?.runtimeVersion);
14
+ if (!runtimeVersion) return null;
15
+ const approvedVersion = validVersion(status.latestApprovedVersion);
16
+ if (status.updateAvailable === true && approvedVersion && compareVersions(approvedVersion, runtimeVersion) > 0) {
17
+ return { runtimeVersion, targetVersion: approvedVersion, kind: "approved", autoUpdateEnabled: status.autoUpdateEnabled };
18
+ }
19
+ const diskVersion = validVersion(status.runtimePackageVersionOnDisk);
20
+ if (status.reconnectRequired === true && diskVersion && diskVersion !== runtimeVersion) {
21
+ return { runtimeVersion, targetVersion: diskVersion, kind: "disk", autoUpdateEnabled: status.autoUpdateEnabled };
22
+ }
23
+ return null;
24
+ }
25
+
26
+ function noticeText(target) {
27
+ const lines = [target.kind === "approved"
28
+ ? `[AImakeAll MCP 업데이트 안내] 현재 실행 ${target.runtimeVersion} → 공식 승인 ${target.targetVersion}.`
29
+ : `[AImakeAll MCP 재연결 안내] 현재 실행 ${target.runtimeVersion}, 디스크 런타임 패키지 ${target.targetVersion}: 실행 중인 버전은 아직 바뀌지 않았습니다.`];
30
+ if (target.autoUpdateEnabled === false) {
31
+ lines.push("자동 업데이트가 꺼져 있습니다. 승인 버전을 자동 적용하려면 AIMAKEALL_MCP_AUTO_UPDATE 설정을 켠 뒤 재연결하세요.");
32
+ }
33
+ lines.push("현재 작업이 모두 끝난 유휴 상태에서 /mcp → aimakeall → Reconnect를 선택하세요. 다른 MCP 클라이언트에서는 AImakeAll MCP 연결을 재연결하세요.");
34
+ lines.push("진행 중인 작업이나 MCP 프로세스를 강제 종료하지 마세요. 이 안내는 업데이트 설치·재시작·도구 재실행을 하지 않습니다.");
35
+ return lines.join("\n");
36
+ }
37
+
38
+ /**
39
+ * Register ordinary tools through this facade; registration/listing does no work.
40
+ * A real invocation checks release status alongside the handler, at most once per
41
+ * interval. Only the first returned result for a target version gets a notice.
42
+ */
43
+ export function createUpdateNoticeTools(server, { readStatus, now = Date.now, intervalMs = DEFAULT_INTERVAL_MS } = {}) {
44
+ const interval = Number.isFinite(intervalMs) && intervalMs >= 0 ? intervalMs : DEFAULT_INTERVAL_MS;
45
+ const announcedTargets = new Set();
46
+ let cachedStatus = null;
47
+ let checkedAt = null;
48
+ let flight = null;
49
+
50
+ const currentTime = () => {
51
+ try { const value = now(); return typeof value === "number" && Number.isFinite(value) ? value : null; }
52
+ catch { return null; }
53
+ };
54
+
55
+ function checkStatus() {
56
+ if (flight) return flight;
57
+ const instant = currentTime();
58
+ if (checkedAt !== null && instant !== null && instant >= checkedAt && instant - checkedAt < interval) {
59
+ return Promise.resolve(cachedStatus);
60
+ }
61
+ const pending = Promise.resolve()
62
+ .then(() => typeof readStatus === "function" ? readStatus({ checkForUpdates: true }) : null)
63
+ .then((status) => { cachedStatus = status; return status; }, () => { cachedStatus = null; return null; })
64
+ .finally(() => {
65
+ checkedAt = currentTime();
66
+ if (flight === pending) flight = null;
67
+ });
68
+ flight = pending;
69
+ return pending;
70
+ }
71
+
72
+ function wrapHandler(handler) {
73
+ return async function (...args) {
74
+ const statusPromise = checkStatus();
75
+ // Run exactly once; a thrown/rejected original error is never intercepted.
76
+ const result = await Reflect.apply(handler, this, args);
77
+ try {
78
+ await statusPromise;
79
+ // A long-running tool may finish after a newer check. Do not announce an
80
+ // older cached release if a later check has replaced it (even with unknown).
81
+ const target = updateTarget(cachedStatus);
82
+ if (!target || announcedTargets.has(target.targetVersion)
83
+ || !result || typeof result !== "object" || Array.isArray(result)
84
+ || (result.content !== undefined && !Array.isArray(result.content))) return result;
85
+ const output = { ...result, content: [...(result.content || []), { type: "text", text: noticeText(target) }] };
86
+ announcedTargets.add(target.targetVersion);
87
+ return output;
88
+ } catch {
89
+ // Status/formatting failures cannot change the original tool result.
90
+ return result;
91
+ }
92
+ };
93
+ }
94
+
95
+ return Object.fromEntries(["tool", "registerTool"].map((method) => [method, (...args) => {
96
+ const handler = args.at(-1);
97
+ const registration = typeof handler === "function" ? [...args.slice(0, -1), wrapHandler(handler)] : args;
98
+ return Reflect.apply(server[method], server, registration);
99
+ }]));
100
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aimakeall-mcp",
3
- "version": "0.14.0",
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
  }
package/runtime.mjs CHANGED
@@ -5,6 +5,9 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
5
5
  import { createApiClient } from "./lib/api-client.mjs";
6
6
  import { registerCloudTools } from "./lib/cloud-tools.mjs";
7
7
  import { registerCompanionTools } from "./lib/companion-tools.mjs";
8
+ import { registerMcpStatusTools, MCP_STATUS_INSTRUCTIONS } from "./lib/status-tools.mjs";
9
+ import { createMcpStatusReader } from "./lib/mcp-status.mjs";
10
+ import { createUpdateNoticeTools } from "./lib/update-notice.mjs";
8
11
  import { MCP_PROXY_VERSION, resolveConfig } from "./lib/config.mjs";
9
12
  import { pruneOldMedia } from "./lib/media-store.mjs";
10
13
 
@@ -15,10 +18,13 @@ const config = selfCheck ? {
15
18
  apiBase: "https://aimakeall.com", companionUrl: "http://127.0.0.1:9876",
16
19
  pat: "", patLooksValid: false, outputDir: "", stateDir: "", version: MCP_PROXY_VERSION,
17
20
  } : resolveConfig();
18
- const server = new McpServer({ name: "aimakeall", version: MCP_PROXY_VERSION });
21
+ const server = new McpServer({ name: "aimakeall", version: MCP_PROXY_VERSION }, { instructions: MCP_STATUS_INSTRUCTIONS });
19
22
  const api = createApiClient({ apiBase: config.apiBase, pat: config.pat });
20
- registerCloudTools(server, config, api);
21
- registerCompanionTools(server, config);
23
+ const readStatus = createMcpStatusReader({ runtimeVersion: MCP_PROXY_VERSION });
24
+ registerMcpStatusTools(server, config, { readStatus });
25
+ const toolServer = createUpdateNoticeTools(server, { readStatus });
26
+ registerCloudTools(toolServer, config, api);
27
+ registerCompanionTools(toolServer, config);
22
28
 
23
29
  if (selfCheck) {
24
30
  console.log(JSON.stringify({ ok: true, version: MCP_PROXY_VERSION }));