UNPKG

@kajidog/mcp-tts-voicevox

Version:

VOICEVOX integration for MCP - Text-to-Speech server using VOICEVOX engine

1,635 lines (1,613 loc) 117 kB
#!/usr/bin/env node // ../../packages/mcp-core/dist/config-schema.js function parseCliFromDefs(defs, argv) { const config2 = {}; const flagMap = /* @__PURE__ */ new Map(); const negationMap = /* @__PURE__ */ new Map(); for (const [key, def] of Object.entries(defs)) { flagMap.set(def.cli, { key, def }); if (def.type === "boolean") { const baseName = def.cli.replace(/^--/, ""); negationMap.set(`--no-${baseName}`, key); } } for (let i = 0; i < argv.length; i++) { const arg = argv[i]; const nextArg = argv[i + 1]; const negKey = negationMap.get(arg); if (negKey !== void 0) { config2[negKey] = false; continue; } const entry = flagMap.get(arg); if (!entry) continue; const { key, def } = entry; switch (def.type) { case "boolean": config2[key] = true; break; case "string": if (nextArg && !nextArg.startsWith("-")) { config2[key] = nextArg; i++; } break; case "number": if (nextArg && !nextArg.startsWith("-")) { const num = Number(nextArg); if (Number.isFinite(num)) { config2[key] = num; } i++; } break; case "string[]": if (nextArg && !nextArg.startsWith("-")) { config2[key] = nextArg.split(",").map((s) => s.trim()); i++; } break; } } return config2; } function parseEnvFromDefs(defs, env) { const config2 = {}; for (const [key, def] of Object.entries(defs)) { if (!def.env) continue; const val = env[def.env]; if (val === void 0) continue; switch (def.type) { case "boolean": if (val === "true") config2[key] = true; else if (val === "false") config2[key] = false; break; case "number": { if (val === "") break; const num = Number(val); if (Number.isFinite(num)) config2[key] = num; break; } case "string": if (val) config2[key] = val; break; case "string[]": if (val) config2[key] = val.split(",").map((s) => s.trim()); break; } } return config2; } function parseConfigFileFromDefs(defs, fileContent) { const config2 = {}; const cliNameMap = /* @__PURE__ */ new Map(); for (const [key, def] of Object.entries(defs)) { const cliName = def.cli.replace(/^--/, ""); cliNameMap.set(cliName, { key, def }); const camelCase = cliName.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); if (camelCase !== cliName) { cliNameMap.set(camelCase, { key, def }); } cliNameMap.set(key, { key, def }); } for (const [fileKey, val] of Object.entries(fileContent)) { const entry = cliNameMap.get(fileKey); if (!entry) continue; const { key, def } = entry; switch (def.type) { case "boolean": if (typeof val === "boolean") config2[key] = val; break; case "number": if (typeof val === "number" && Number.isFinite(val)) config2[key] = val; break; case "string": if (typeof val === "string" && val !== "") config2[key] = val; break; case "string[]": if (Array.isArray(val)) config2[key] = val.filter((v) => typeof v === "string"); else if (typeof val === "string") config2[key] = val.split(",").map((s) => s.trim()); break; } } return config2; } function getDefaultsFromDefs(defs) { const defaults = {}; for (const [key, def] of Object.entries(defs)) { if (def.default !== void 0) { defaults[key] = def.default; } } return defaults; } function filterUndefined(obj) { return Object.fromEntries(Object.entries(obj).filter(([_, v]) => v !== void 0)); } // ../../packages/mcp-core/dist/config.js var baseConfigDefs = { httpMode: { cli: "--http", env: "MCP_HTTP_MODE", description: "Enable HTTP server mode", group: "Server Options", type: "boolean", default: false }, httpPort: { cli: "--port", env: "MCP_HTTP_PORT", description: "HTTP server port", group: "Server Options", type: "number", default: 3e3, valueName: "<port>" }, httpHost: { cli: "--host", env: "MCP_HTTP_HOST", description: "HTTP server host", group: "Server Options", type: "string", default: "0.0.0.0", valueName: "<host>" }, allowedHosts: { cli: "--allowed-hosts", env: "MCP_ALLOWED_HOSTS", description: "Comma-separated list of allowed hosts", group: "Server Options", type: "string[]", default: ["localhost", "127.0.0.1", "[::1]"], valueName: "<hosts>" }, allowedOrigins: { cli: "--allowed-origins", env: "MCP_ALLOWED_ORIGINS", description: "Comma-separated list of allowed origins", group: "Server Options", type: "string[]", default: ["http://localhost", "http://127.0.0.1", "https://localhost", "https://127.0.0.1"], valueName: "<origins>" }, apiKey: { cli: "--api-key", env: "MCP_API_KEY", description: "Require matching API key via X-API-Key or Authorization: Bearer", group: "Server Options", type: "string", valueName: "<key>" } }; var defaultBaseConfig = getDefaultsFromDefs(baseConfigDefs); // ../../packages/mcp-core/dist/http.js import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; // ../../node_modules/.pnpm/hono@4.12.23/node_modules/hono/dist/request/constants.js var GET_MATCH_RESULT = /* @__PURE__ */ Symbol(); // ../../node_modules/.pnpm/hono@4.12.23/node_modules/hono/dist/utils/body.js var parseBody = async (request, options = /* @__PURE__ */ Object.create(null)) => { const { all = false, dot = false } = options; const headers = request instanceof HonoRequest ? request.raw.headers : request.headers; const contentType = headers.get("Content-Type"); if (contentType?.startsWith("multipart/form-data") || contentType?.startsWith("application/x-www-form-urlencoded")) { return parseFormData(request, { all, dot }); } return {}; }; async function parseFormData(request, options) { const formData = await request.formData(); if (formData) { return convertFormDataToBodyData(formData, options); } return {}; } function convertFormDataToBodyData(formData, options) { const form = /* @__PURE__ */ Object.create(null); formData.forEach((value, key) => { const shouldParseAllValues = options.all || key.endsWith("[]"); if (!shouldParseAllValues) { form[key] = value; } else { handleParsingAllValues(form, key, value); } }); if (options.dot) { Object.entries(form).forEach(([key, value]) => { const shouldParseDotValues = key.includes("."); if (shouldParseDotValues) { handleParsingNestedValues(form, key, value); delete form[key]; } }); } return form; } var handleParsingAllValues = (form, key, value) => { if (form[key] !== void 0) { if (Array.isArray(form[key])) { ; form[key].push(value); } else { form[key] = [form[key], value]; } } else { if (!key.endsWith("[]")) { form[key] = value; } else { form[key] = [value]; } } }; var handleParsingNestedValues = (form, key, value) => { if (/(?:^|\.)__proto__\./.test(key)) { return; } let nestedForm = form; const keys = key.split("."); keys.forEach((key2, index) => { if (index === keys.length - 1) { nestedForm[key2] = value; } else { if (!nestedForm[key2] || typeof nestedForm[key2] !== "object" || Array.isArray(nestedForm[key2]) || nestedForm[key2] instanceof File) { nestedForm[key2] = /* @__PURE__ */ Object.create(null); } nestedForm = nestedForm[key2]; } }); }; // ../../node_modules/.pnpm/hono@4.12.23/node_modules/hono/dist/utils/url.js var tryDecode = (str, decoder) => { try { return decoder(str); } catch { return str.replace(/(?:%[0-9A-Fa-f]{2})+/g, (match2) => { try { return decoder(match2); } catch { return match2; } }); } }; var _decodeURI = (value) => { if (!/[%+]/.test(value)) { return value; } if (value.indexOf("+") !== -1) { value = value.replace(/\+/g, " "); } return value.indexOf("%") !== -1 ? tryDecode(value, decodeURIComponent_) : value; }; var _getQueryParam = (url, key, multiple) => { let encoded; if (!multiple && key && !/[%+]/.test(key)) { let keyIndex2 = url.indexOf("?", 8); if (keyIndex2 === -1) { return void 0; } if (!url.startsWith(key, keyIndex2 + 1)) { keyIndex2 = url.indexOf(`&${key}`, keyIndex2 + 1); } while (keyIndex2 !== -1) { const trailingKeyCode = url.charCodeAt(keyIndex2 + key.length + 1); if (trailingKeyCode === 61) { const valueIndex = keyIndex2 + key.length + 2; const endIndex = url.indexOf("&", valueIndex); return _decodeURI(url.slice(valueIndex, endIndex === -1 ? void 0 : endIndex)); } else if (trailingKeyCode == 38 || isNaN(trailingKeyCode)) { return ""; } keyIndex2 = url.indexOf(`&${key}`, keyIndex2 + 1); } encoded = /[%+]/.test(url); if (!encoded) { return void 0; } } const results = {}; encoded ??= /[%+]/.test(url); let keyIndex = url.indexOf("?", 8); while (keyIndex !== -1) { const nextKeyIndex = url.indexOf("&", keyIndex + 1); let valueIndex = url.indexOf("=", keyIndex); if (valueIndex > nextKeyIndex && nextKeyIndex !== -1) { valueIndex = -1; } let name = url.slice( keyIndex + 1, valueIndex === -1 ? nextKeyIndex === -1 ? void 0 : nextKeyIndex : valueIndex ); if (encoded) { name = _decodeURI(name); } keyIndex = nextKeyIndex; if (name === "") { continue; } let value; if (valueIndex === -1) { value = ""; } else { value = url.slice(valueIndex + 1, nextKeyIndex === -1 ? void 0 : nextKeyIndex); if (encoded) { value = _decodeURI(value); } } if (multiple) { if (!(results[name] && Array.isArray(results[name]))) { results[name] = []; } ; results[name].push(value); } else { results[name] ??= value; } } return key ? results[key] : results; }; var getQueryParam = _getQueryParam; var getQueryParams = (url, key) => { return _getQueryParam(url, key, true); }; var decodeURIComponent_ = decodeURIComponent; // ../../node_modules/.pnpm/hono@4.12.23/node_modules/hono/dist/request.js var tryDecodeURIComponent = (str) => tryDecode(str, decodeURIComponent_); var HonoRequest = class { /** * `.raw` can get the raw Request object. * * @see {@link https://hono.dev/docs/api/request#raw} * * @example * ```ts * // For Cloudflare Workers * app.post('/', async (c) => { * const metadata = c.req.raw.cf?.hostMetadata? * ... * }) * ``` */ raw; #validatedData; // Short name of validatedData #matchResult; routeIndex = 0; /** * `.path` can get the pathname of the request. * * @see {@link https://hono.dev/docs/api/request#path} * * @example * ```ts * app.get('/about/me', (c) => { * const pathname = c.req.path // `/about/me` * }) * ``` */ path; bodyCache = {}; constructor(request, path = "/", matchResult = [[]]) { this.raw = request; this.path = path; this.#matchResult = matchResult; this.#validatedData = {}; } param(key) { return key ? this.#getDecodedParam(key) : this.#getAllDecodedParams(); } #getDecodedParam(key) { const paramKey = this.#matchResult[0][this.routeIndex][1][key]; const param = this.#getParamValue(paramKey); return param && /\%/.test(param) ? tryDecodeURIComponent(param) : param; } #getAllDecodedParams() { const decoded = {}; const keys = Object.keys(this.#matchResult[0][this.routeIndex][1]); for (const key of keys) { const value = this.#getParamValue(this.#matchResult[0][this.routeIndex][1][key]); if (value !== void 0) { decoded[key] = /\%/.test(value) ? tryDecodeURIComponent(value) : value; } } return decoded; } #getParamValue(paramKey) { return this.#matchResult[1] ? this.#matchResult[1][paramKey] : paramKey; } query(key) { return getQueryParam(this.url, key); } queries(key) { return getQueryParams(this.url, key); } header(name) { if (name) { return this.raw.headers.get(name) ?? void 0; } const headerData = {}; this.raw.headers.forEach((value, key) => { headerData[key] = value; }); return headerData; } async parseBody(options) { return parseBody(this, options); } #cachedBody = (key) => { const { bodyCache, raw } = this; const cachedBody = bodyCache[key]; if (cachedBody) { return cachedBody; } const anyCachedKey = Object.keys(bodyCache)[0]; if (anyCachedKey) { return bodyCache[anyCachedKey].then((body) => { if (anyCachedKey === "json") { body = JSON.stringify(body); } return new Response(body)[key](); }); } return bodyCache[key] = raw[key](); }; /** * `.json()` can parse Request body of type `application/json` * * @see {@link https://hono.dev/docs/api/request#json} * * @example * ```ts * app.post('/entry', async (c) => { * const body = await c.req.json() * }) * ``` */ json() { return this.#cachedBody("text").then((text) => JSON.parse(text)); } /** * `.text()` can parse Request body of type `text/plain` * * @see {@link https://hono.dev/docs/api/request#text} * * @example * ```ts * app.post('/entry', async (c) => { * const body = await c.req.text() * }) * ``` */ text() { return this.#cachedBody("text"); } /** * `.arrayBuffer()` parse Request body as an `ArrayBuffer` * * @see {@link https://hono.dev/docs/api/request#arraybuffer} * * @example * ```ts * app.post('/entry', async (c) => { * const body = await c.req.arrayBuffer() * }) * ``` */ arrayBuffer() { return this.#cachedBody("arrayBuffer"); } /** * `.bytes()` parses the request body as a `Uint8Array`. * * @see {@link https://hono.dev/docs/api/request#bytes} * * @example * ```ts * app.post('/entry', async (c) => { * const body = await c.req.bytes() * }) * ``` */ bytes() { return this.#cachedBody("arrayBuffer").then((buffer) => new Uint8Array(buffer)); } /** * Parses the request body as a `Blob`. * @example * ```ts * app.post('/entry', async (c) => { * const body = await c.req.blob(); * }); * ``` * @see https://hono.dev/docs/api/request#blob */ blob() { return this.#cachedBody("blob"); } /** * Parses the request body as `FormData`. * @example * ```ts * app.post('/entry', async (c) => { * const body = await c.req.formData(); * }); * ``` * @see https://hono.dev/docs/api/request#formdata */ formData() { return this.#cachedBody("formData"); } /** * Adds validated data to the request. * * @param target - The target of the validation. * @param data - The validated data to add. */ addValidatedData(target, data) { this.#validatedData[target] = data; } valid(target) { return this.#validatedData[target]; } /** * `.url()` can get the request url strings. * * @see {@link https://hono.dev/docs/api/request#url} * * @example * ```ts * app.get('/about/me', (c) => { * const url = c.req.url // `http://localhost:8787/about/me` * ... * }) * ``` */ get url() { return this.raw.url; } /** * `.method()` can get the method name of the request. * * @see {@link https://hono.dev/docs/api/request#method} * * @example * ```ts * app.get('/about/me', (c) => { * const method = c.req.method // `GET` * }) * ``` */ get method() { return this.raw.method; } get [GET_MATCH_RESULT]() { return this.#matchResult; } /** * `.matchedRoutes()` can return a matched route in the handler * * @deprecated * * Use matchedRoutes helper defined in "hono/route" instead. * * @see {@link https://hono.dev/docs/api/request#matchedroutes} * * @example * ```ts * app.use('*', async function logger(c, next) { * await next() * c.req.matchedRoutes.forEach(({ handler, method, path }, i) => { * const name = handler.name || (handler.length < 2 ? '[handler]' : '[middleware]') * console.log( * method, * ' ', * path, * ' '.repeat(Math.max(10 - path.length, 0)), * name, * i === c.req.routeIndex ? '<- respond from here' : '' * ) * }) * }) * ``` */ get matchedRoutes() { return this.#matchResult[0].map(([[, route]]) => route); } /** * `routePath()` can retrieve the path registered within the handler * * @deprecated * * Use routePath helper defined in "hono/route" instead. * * @see {@link https://hono.dev/docs/api/request#routepath} * * @example * ```ts * app.get('/posts/:id', (c) => { * return c.json({ path: c.req.routePath }) * }) * ``` */ get routePath() { return this.#matchResult[0].map(([[, route]]) => route)[this.routeIndex].path; } }; // ../../node_modules/.pnpm/hono@4.12.23/node_modules/hono/dist/router/reg-exp-router/node.js var regExpMetaChars = new Set(".\\+*[^]$()"); // ../../packages/mcp-core/dist/stdio.js import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; async function connectStdio(server2) { await server2.connect(new StdioServerTransport()); } // src/server.ts import { VoicevoxClient } from "@kajidog/voicevox-client"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; // src/config.ts import { existsSync, readFileSync } from "fs"; import { join, resolve } from "path"; var voicevoxConfigDefs = { voicevoxUrl: { cli: "--url", env: "VOICEVOX_URL", description: "VOICEVOX Engine URL", group: "Voicevox Configuration", type: "string", default: "http://localhost:50021", valueName: "<url>" }, defaultSpeaker: { cli: "--speaker", env: "VOICEVOX_DEFAULT_SPEAKER", description: "Default speaker ID", group: "Voicevox Configuration", type: "number", default: 1, valueName: "<id>" }, defaultSpeedScale: { cli: "--speed", env: "VOICEVOX_DEFAULT_SPEED_SCALE", description: "Default playback speed", group: "Voicevox Configuration", type: "number", default: 1, valueName: "<scale>" }, retryCount: { cli: "--retry-count", env: "VOICEVOX_RETRY_COUNT", description: "Number of retries for failed VOICEVOX API requests (0 disables)", group: "Voicevox Configuration", type: "number", default: 2, valueName: "<count>" }, retryDelayMs: { cli: "--retry-delay-ms", env: "VOICEVOX_RETRY_DELAY_MS", description: "Initial retry delay in milliseconds (exponential backoff)", group: "Voicevox Configuration", type: "number", default: 250, valueName: "<ms>" }, useStreaming: { cli: "--use-streaming", env: "VOICEVOX_USE_STREAMING", description: "Enable streaming playback (ffplay required)", group: "Playback Options", type: "boolean" }, defaultPostPhonemeLength: { cli: "--post-phoneme-length", env: "VOICEVOX_DEFAULT_POST_PHONEME_LENGTH", description: "Trailing silence per speech segment in seconds (default: VOICEVOX engine default). Increase for a longer pause between queued segments; with streaming playback (ffplay) the silence also protects the end of speech from being cut off", group: "Playback Options", type: "number", valueName: "<sec>" }, defaultImmediate: { cli: "--immediate", env: "VOICEVOX_DEFAULT_IMMEDIATE", description: "Enable immediate playback", group: "Playback Options", type: "boolean", default: true }, defaultWaitForStart: { cli: "--wait-for-start", env: "VOICEVOX_DEFAULT_WAIT_FOR_START", description: "Wait for playback to start", group: "Playback Options", type: "boolean", default: false }, defaultWaitForEnd: { cli: "--wait-for-end", env: "VOICEVOX_DEFAULT_WAIT_FOR_END", description: "Wait for playback to end", group: "Playback Options", type: "boolean", default: false }, restrictImmediate: { cli: "--restrict-immediate", env: "VOICEVOX_RESTRICT_IMMEDIATE", description: "Restrict AI from using immediate option", group: "Restriction Options", type: "boolean", default: false }, restrictWaitForStart: { cli: "--restrict-wait-for-start", env: "VOICEVOX_RESTRICT_WAIT_FOR_START", description: "Restrict AI from using waitForStart option", group: "Restriction Options", type: "boolean", default: false }, restrictWaitForEnd: { cli: "--restrict-wait-for-end", env: "VOICEVOX_RESTRICT_WAIT_FOR_END", description: "Restrict AI from using waitForEnd option", group: "Restriction Options", type: "boolean", default: false }, disabledTools: { cli: "--disable-tools", env: "VOICEVOX_DISABLED_TOOLS", description: "Comma-separated list of tools to disable", group: "Tool Options", type: "string[]", default: [], valueName: "<tools>" }, disabledGroups: { cli: "--disable-groups", env: "VOICEVOX_DISABLED_GROUPS", description: "Comma-separated list of tool groups to disable. Built-in groups: player (all player UI tools), dictionary (all dictionary read+write tools), file (synthesize_file), apps (MCP App UI tools)", group: "Tool Options", type: "string[]", default: [], valueName: "<groups>" }, autoPlay: { cli: "--auto-play", env: "VOICEVOX_AUTO_PLAY", description: "Auto-play audio in UI player", group: "UI Player Options", type: "boolean", default: true }, playerExportEnabled: { cli: "--player-export", env: "VOICEVOX_PLAYER_EXPORT_ENABLED", description: "Enable track export(download) in UI player", group: "UI Player Options", type: "boolean", default: true }, playerExportDir: { cli: "--player-export-dir", env: "VOICEVOX_PLAYER_EXPORT_DIR", description: "Default output directory for exported tracks", group: "UI Player Options", type: "string", valueName: "<dir>" }, playerCacheDir: { cli: "--player-cache-dir", env: "VOICEVOX_PLAYER_CACHE_DIR", description: "Player cache directory", group: "UI Player Options", type: "string", valueName: "<dir>" }, playerStateFile: { cli: "--player-state-file", env: "VOICEVOX_PLAYER_STATE_FILE", description: "Persisted player state file path", group: "UI Player Options", type: "string", valueName: "<path>" }, playerAudioCacheEnabled: { cli: "--player-audio-cache", env: "VOICEVOX_PLAYER_AUDIO_CACHE_ENABLED", description: "Enable disk audio cache for player", group: "UI Player Options", type: "boolean", default: true }, playerAudioCacheTtlDays: { cli: "--player-audio-cache-ttl-days", env: "VOICEVOX_PLAYER_AUDIO_CACHE_TTL_DAYS", description: "Audio cache retention days (0 disables, -1 unlimited)", group: "UI Player Options", type: "number", default: 30, valueName: "<days>" }, playerAudioCacheMaxMb: { cli: "--player-audio-cache-max-mb", env: "VOICEVOX_PLAYER_AUDIO_CACHE_MAX_MB", description: "Audio cache size cap in MB (0 disables, -1 unlimited)", group: "UI Player Options", type: "number", default: 512, valueName: "<mb>" }, playerDomain: { cli: "--player-domain", env: "VOICEVOX_PLAYER_DOMAIN", description: "Player domain", group: "UI Player Options", type: "string", default: "", valueName: "<domain>" }, configFile: { cli: "--config", env: "VOICEVOX_CONFIG", description: "Path to config file (.voicevoxrc.json)", group: "Utility Options", type: "string", valueName: "<path>" } }; var allConfigDefs = { ...voicevoxConfigDefs, ...baseConfigDefs }; function getPathDefaults() { return { playerExportDir: join(process.cwd(), "voicevox-player-exports"), playerCacheDir: join(process.cwd(), ".voicevox-player-cache"), playerStateFile: join(process.cwd(), ".voicevox-player-cache", "player-state.json") }; } function createDefaultConfig() { const schemaDefs = getDefaultsFromDefs(allConfigDefs); const pathDefs = getPathDefaults(); return { ...schemaDefs, ...pathDefs }; } function parseCliArgs(argv = process.argv.slice(2)) { return parseCliFromDefs(allConfigDefs, argv); } function parseEnvVars(env = process.env) { return parseEnvFromDefs(allConfigDefs, env); } function parseConfigFile(configPath) { const filePath = configPath ? resolve(configPath) : join(process.cwd(), ".voicevoxrc.json"); if (!existsSync(filePath)) { return {}; } try { const content = JSON.parse(readFileSync(filePath, "utf-8")); return parseConfigFileFromDefs(allConfigDefs, content); } catch { return {}; } } function getConfig(argv, env) { const cliConfig = parseCliArgs(argv); const envConfig = parseEnvVars(env); const configFilePath = cliConfig.configFile ?? envConfig.configFile; const fileConfig = parseConfigFile(configFilePath); const defaultConfig = createDefaultConfig(); const merged = { ...defaultConfig, ...filterUndefined(fileConfig), ...filterUndefined(envConfig), ...filterUndefined(cliConfig) }; const isPlayerStateFileExplicit = envConfig.playerStateFile !== void 0 || cliConfig.playerStateFile !== void 0 || fileConfig.playerStateFile !== void 0; if (!isPlayerStateFileExplicit) { merged.playerStateFile = join(merged.playerCacheDir, "player-state.json"); } ; merged.configFile = void 0; return merged; } // src/tool-groups.ts var TOOL_GROUPS = { /** All player UI tools */ player: ["speak_player", "resynthesize_player", "get_player_state", "open_dictionary_ui"], /** All dictionary tools (read + write) */ dictionary: [ "get_accent_phrases", "get_user_dictionary", "add_user_dictionary_word", "update_user_dictionary_word", "delete_user_dictionary_word", "add_user_dictionary_words", "update_user_dictionary_words" ], /** Audio file synthesis tool */ file: ["synthesize_file"], /** MCP App tools (tools registered as UI apps, i.e. with registerAppTool) */ apps: ["speak_player", "resynthesize_player", "open_dictionary_ui"] }; function expandGroups(groupNames) { const tools = []; for (const name of groupNames) { const members = TOOL_GROUPS[name]; if (members) { tools.push(...members); } else { console.error(`[mcp-tts] Unknown tool group: "${name}". Valid groups: ${Object.keys(TOOL_GROUPS).join(", ")}`); } } return tools; } // src/tools/dictionary.ts import * as z from "zod"; // src/tools/player/dictionary-revision.ts var playerDictionaryRevision = 0; function bumpPlayerDictionaryRevision() { playerDictionaryRevision += 1; } function getPlayerDictionaryRevision() { return playerDictionaryRevision; } // src/tools/registration.ts import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"; var TOOL_PREFIX = "voicevox_"; function addToolPrefix(name) { if (name.startsWith("_")) { return name; } return `${TOOL_PREFIX}${name}`; } function isToolDisabled(disabledTools, name) { const fullName = addToolPrefix(name); return disabledTools.has(name) || disabledTools.has(fullName); } function isToolEnabled(disabledTools, name) { return !isToolDisabled(disabledTools, name); } function registerToolIfEnabled(server2, disabledTools, name, ...args) { const fullName = addToolPrefix(name); if (isToolDisabled(disabledTools, name)) { console.error(`Tool "${fullName}" is disabled via configuration`); return; } server2.registerTool(fullName, ...args); } function registerAppToolIfEnabled(server2, disabledTools, name, ...args) { const fullName = addToolPrefix(name); if (isToolDisabled(disabledTools, name)) { console.error(`Tool "${fullName}" is disabled via configuration`); return; } registerAppTool(server2, fullName, ...args); } // src/tools/utils.ts import { formatSpeakResponse, parseAudioQuery, parseStringInput } from "@kajidog/voicevox-client"; var createErrorResponse = (error) => ({ content: [ { type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` } ], isError: true }); var createSuccessResponse = (text) => ({ content: [{ type: "text", text }] }); var getEffectiveSpeaker = (explicitSpeaker, extra) => { if (explicitSpeaker !== void 0) return explicitSpeaker; const raw = extra?.requestInfo?.headers?.["x-voicevox-speaker"]; const value = Array.isArray(raw) ? raw[0] : raw; if (value !== void 0) { const parsed = Number.parseInt(value, 10); if (!Number.isNaN(parsed) && parsed >= 0) return parsed; } return void 0; }; var processTextInput = async (voicevoxClient, text, speaker, speedScale, playbackOptions) => { const segments = parseStringInput(text); return await voicevoxClient.speak(segments, { speaker, speedScale, ...playbackOptions }); }; // src/tools/dictionary.ts function registerDictionaryTools(deps) { const { server: server2, voicevoxClient, config: config2, disabledTools } = deps; registerToolIfEnabled( server2, disabledTools, "get_accent_phrases", { title: "Get Accent Phrases", description: 'Get accent phrases (reading and accent positions) from text. Returns inline notation like "\u30B3\u30F3[\u30CB]\u30C1\u30EF,\u30BB[\u30AB]\u30A4" where brackets indicate accent position.', inputSchema: { text: z.string().describe("Text to analyze"), speaker: z.number().optional().describe("Speaker ID (optional, affects pronunciation)") }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true } }, async ({ text, speaker }) => { try { const result = await voicevoxClient.getAccentNotation(text, speaker ?? config2.defaultSpeaker); return { content: [ { type: "text", text: JSON.stringify(result) } ] }; } catch (error) { return createErrorResponse(error); } } ); registerToolIfEnabled( server2, disabledTools, "get_user_dictionary", { title: "Get User Dictionary", description: 'Get words in the VOICEVOX user dictionary. Supports filtering by query and pagination. Pronunciation uses inline accent notation (e.g. "\u30DC\u30A4\u30B9[\u30DC\u30C3]\u30AF\u30B9").', inputSchema: { query: z.string().optional().describe("Filter by surface or pronunciation (partial match, case-insensitive)"), offset: z.number().int().min(0).optional().describe("Pagination offset (default: 0)"), limit: z.number().int().min(1).max(200).optional().describe("Max words to return (default: 50)") }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true } }, async ({ query, offset, limit }) => { try { let words = await voicevoxClient.getDictionary(); if (query) { const q = query.toLowerCase(); words = words.filter( (w) => w.surface.toLowerCase().includes(q) || w.pronunciation.toLowerCase().includes(q) || w.notation.toLowerCase().includes(q) ); } const totalCount = words.length; const effectiveOffset = offset ?? 0; const effectiveLimit = limit ?? 50; const paged = words.slice(effectiveOffset, effectiveOffset + effectiveLimit); return { content: [ { type: "text", text: JSON.stringify({ words: paged, totalCount, offset: effectiveOffset, limit: effectiveLimit }) } ] }; } catch (error) { return createErrorResponse(error); } } ); registerToolIfEnabled( server2, disabledTools, "add_user_dictionary_word", { title: "Add User Dictionary Word", description: 'Add a word to the VOICEVOX user dictionary. Pronunciation supports inline accent notation (e.g. "\u30DC\u30A4\u30B9[\u30DC\u30C3]\u30AF\u30B9"). If brackets are omitted, accent is auto-estimated.', inputSchema: { surface: z.string().describe("Word surface form (the text to match)"), pronunciation: z.string().describe('Katakana reading with optional inline accent notation (e.g. "\u30DC\u30A4\u30B9[\u30DC\u30C3]\u30AF\u30B9")'), priority: z.number().int().min(0).max(10).optional().describe("Priority 0-10 (default: 5)") }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true } }, async ({ surface, pronunciation, priority }) => { try { const words = await voicevoxClient.addDictionaryWord({ surface, pronunciation, priority }); bumpPlayerDictionaryRevision(); const normalizedSurface = surface.trim(); const word = words.find((w) => w.surface === normalizedSurface) ?? words[words.length - 1]; return { content: [{ type: "text", text: JSON.stringify({ word }) }] }; } catch (error) { return createErrorResponse(error); } } ); registerToolIfEnabled( server2, disabledTools, "update_user_dictionary_word", { title: "Update User Dictionary Word", description: "Update a word in the VOICEVOX user dictionary. surface and pronunciation are optional \u2014 omitted fields keep their existing values. Pronunciation supports inline accent notation.", inputSchema: { wordUuid: z.string().describe("Dictionary word UUID"), surface: z.string().optional().describe("Word surface form (omit to keep existing)"), pronunciation: z.string().optional().describe("Katakana reading with optional inline accent notation (omit to keep existing)"), priority: z.number().int().min(0).max(10).optional().describe("Priority 0-10 (omit to keep existing)") }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true } }, async ({ wordUuid, surface, pronunciation, priority }) => { try { const words = await voicevoxClient.updateDictionaryWord({ wordUuid, surface, pronunciation, priority }); bumpPlayerDictionaryRevision(); const word = words.find((w) => w.wordUuid === wordUuid.trim()); return { content: [{ type: "text", text: JSON.stringify({ word }) }] }; } catch (error) { return createErrorResponse(error); } } ); registerToolIfEnabled( server2, disabledTools, "delete_user_dictionary_word", { title: "Delete User Dictionary Word", description: "Delete a word from the VOICEVOX user dictionary.", inputSchema: { wordUuid: z.string().describe("Dictionary word UUID") }, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true } }, async ({ wordUuid }) => { try { const normalizedWordUuid = wordUuid.trim(); if (!normalizedWordUuid) throw new Error("wordUuid is required"); await voicevoxClient.deleteDictionaryWord(normalizedWordUuid); bumpPlayerDictionaryRevision(); return { content: [{ type: "text", text: JSON.stringify({ success: true, deletedWordUuid: normalizedWordUuid }) }] }; } catch (error) { return createErrorResponse(error); } } ); registerToolIfEnabled( server2, disabledTools, "add_user_dictionary_words", { title: "Bulk Add User Dictionary Words", description: "Add multiple words to the VOICEVOX user dictionary at once. Pronunciation supports inline accent notation.", inputSchema: { words: z.array( z.object({ surface: z.string().describe("Word surface form"), pronunciation: z.string().describe("Katakana reading with optional inline accent notation"), priority: z.number().int().min(0).max(10).optional().describe("Priority 0-10 (default: 5)") }) ).min(1).max(100).describe("Words to add (max 100)") }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true } }, async ({ words }) => { try { const result = await voicevoxClient.addDictionaryWords(words); bumpPlayerDictionaryRevision(); return { content: [ { type: "text", text: JSON.stringify({ addedCount: words.length, words: result }) } ] }; } catch (error) { return createErrorResponse(error); } } ); registerToolIfEnabled( server2, disabledTools, "update_user_dictionary_words", { title: "Bulk Update User Dictionary Words", description: "Update multiple words in the VOICEVOX user dictionary at once. surface and pronunciation are optional per word \u2014 omitted fields keep existing values.", inputSchema: { words: z.array( z.object({ wordUuid: z.string().describe("Dictionary word UUID"), surface: z.string().optional().describe("Word surface form (omit to keep existing)"), pronunciation: z.string().optional().describe("Katakana reading with optional inline accent notation (omit to keep existing)"), priority: z.number().int().min(0).max(10).optional().describe("Priority 0-10 (omit to keep existing)") }) ).min(1).max(100).describe("Words to update (max 100)") }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true } }, async ({ words }) => { try { const result = await voicevoxClient.updateDictionaryWords(words); bumpPlayerDictionaryRevision(); return { content: [ { type: "text", text: JSON.stringify({ updatedCount: words.length, words: result }) } ] }; } catch (error) { return createErrorResponse(error); } } ); } // src/tools/player-ui/context.ts function createPlayerUIToolContext(deps, shared) { const speakerIconCache = /* @__PURE__ */ new Map(); const saveStateForViewAndSession = (stateKey, sessionId, state) => { shared.setSessionState(stateKey, state); if (sessionId && sessionId !== stateKey) { shared.setSessionState(sessionId, state); } }; const resolveSpeakerNameMap = async (segments, defaultSpeaker) => { const list = await shared.getSpeakerList(); const speakerNameMap = /* @__PURE__ */ new Map(); for (const speakerId of [...new Set(segments.map((seg) => seg.speaker ?? defaultSpeaker))]) { const found = list.find((entry) => entry.id === speakerId); speakerNameMap.set(speakerId, found ? `${found.characterName}\uFF08${found.name}\uFF09` : `Speaker ${speakerId}`); } return speakerNameMap; }; return { deps, shared, speakerIconCache, saveStateForViewAndSession, resolveSpeakerNameMap }; } // src/tools/player-ui/dictionary-tools.ts import { accentPhrasesToNotation } from "@kajidog/voicevox-client"; import * as z2 from "zod"; function registerPlayerDictionaryTools(context) { const { deps, shared } = context; const { server: server2, disabledTools, config: config2, voicevoxClient } = deps; const { playerResourceUri: playerResourceUri2, getSpeakerList, synthesizeWithCache } = shared; registerAppToolIfEnabled( server2, disabledTools, "_get_user_dictionary_for_player", { title: "Get User Dictionary (Player)", description: "Get VOICEVOX user dictionary words for the dictionary manager UI.", _meta: { ui: { resourceUri: playerResourceUri2, visibility: ["app"] } } }, async () => { try { const words = await voicevoxClient.getDictionary(); return { content: [{ type: "text", text: JSON.stringify({ words }) }] }; } catch (error) { return createErrorResponse(error); } } ); registerAppToolIfEnabled( server2, disabledTools, "_add_user_dictionary_word_for_player", { title: "Add User Dictionary Word (Player)", description: "Add a word to VOICEVOX user dictionary.", inputSchema: { surface: z2.string().describe("Word surface form"), pronunciation: z2.string().describe("Katakana reading"), accentType: z2.number().int().min(0).optional().describe("Accent nucleus position (1-based mora index, 0=flat). Auto-estimated if omitted."), priority: z2.number().int().min(0).max(10).optional().describe("Priority 0-10") }, _meta: { ui: { resourceUri: playerResourceUri2, visibility: ["app"] } } }, async ({ surface, pronunciation, accentType, priority }) => { try { const words = await voicevoxClient.addDictionaryWord({ surface, pronunciation, accentType, priority }); bumpPlayerDictionaryRevision(); return { content: [{ type: "text", text: JSON.stringify({ words }) }] }; } catch (error) { return createErrorResponse(error); } } ); registerAppToolIfEnabled( server2, disabledTools, "_update_user_dictionary_word_for_player", { title: "Update User Dictionary Word (Player)", description: "Update a VOICEVOX user dictionary word.", inputSchema: { wordUuid: z2.string().describe("Dictionary word UUID"), surface: z2.string().describe("Word surface form"), pronunciation: z2.string().describe("Katakana reading"), accentType: z2.number().int().min(0).optional().describe("Accent nucleus position (1-based mora index, 0=flat). Auto-estimated if omitted."), priority: z2.number().int().min(0).max(10).optional().describe("Priority 0-10") }, _meta: { ui: { resourceUri: playerResourceUri2, visibility: ["app"] } } }, async ({ wordUuid, surface, pronunciation, accentType, priority }) => { try { const words = await voicevoxClient.updateDictionaryWord({ wordUuid, surface, pronunciation, accentType, priority }); bumpPlayerDictionaryRevision(); return { content: [{ type: "text", text: JSON.stringify({ words }) }] }; } catch (error) { return createErrorResponse(error); } } ); registerAppToolIfEnabled( server2, disabledTools, "_delete_user_dictionary_word_for_player", { title: "Delete User Dictionary Word (Player)", description: "Delete a VOICEVOX user dictionary word.", inputSchema: { wordUuid: z2.string().describe("Dictionary word UUID") }, _meta: { ui: { resourceUri: playerResourceUri2, visibility: ["app"] } } }, async ({ wordUuid }) => { try { const words = await voicevoxClient.deleteDictionaryWord(wordUuid); bumpPlayerDictionaryRevision(); return { content: [{ type: "text", text: JSON.stringify({ words }) }] }; } catch (error) { return createErrorResponse(error); } } ); registerAppToolIfEnabled( server2, disabledTools, "_preview_dictionary_word_for_player", { title: "Preview Dictionary Word (Player)", description: "Preview pronunciation with a random speaker.", inputSchema: { text: z2.string().describe("Text to preview"), accentType: z2.number().int().min(0).optional().describe("Optional accent nucleus position for the first phrase (1-based mora index, 0=flat).") }, _meta: { ui: { resourceUri: playerResourceUri2, visibility: ["app"] } } }, async ({ text, accentType }) => { try { const normalizedText = text.trim(); if (!normalizedText) throw new Error("text is required"); const speakers = await getSpeakerList(); if (speakers.length === 0) throw new Error("No speakers available"); const randomSpeaker = speakers[Math.floor(Math.random() * speakers.length)]; const notationResult = await voicevoxClient.getAccentNotation(normalizedText, randomSpeaker.id); let previewAccentPhrases = notationResult.accentPhrases; if (typeof accentType === "number" && previewAccentPhrases.length > 0) { const firstPhrase = previewAccentPhrases[0]; const maxAccent = firstPhrase.moras.length; if (accentType > maxAccent) { throw new Error(`accentType must be between 0 and ${maxAccent}`); } previewAccentPhrases = previewAccentPhrases.map( (phrase, index) => index === 0 ? { ...phrase, accent: accentType } : phrase ); previewAccentPhrases = await shared.playerVoicevoxApi.updateMoraData(previewAccentPhrases, randomSpeaker.id); } const result = await synthesizeWithCache({ text: normalizedText, speaker: randomSpeaker.id, speedScale: config2.defaultSpeedScale, accentPhrases: previewAccentPhrases }); return { content: [ { type: "text", text: JSON.stringify({ audioBase64: result.audioBase64, speaker: result.speaker, speakerName: result.speakerName, kana: result.kana, accentPhrases: previewAccentPhrases, notation: accentPhrasesToNotation(previewAccentPhrases) }) } ] }; } catch (error) { return createErrorResponse(error); } } ); } // src/tools/player-ui/export-tools.ts import { spawn as spawn2 } from "child_process"; import { mkdir, writeFile } from "fs/promises"; import { join as join2 } from "path"; import * as z3 from "zod"; // src/tools/player-ui/os-utils.ts import { spawn, spawnSync } from "child_process"; import { dirname, resolve as resolve2 } from "path"; var commandExistsCache = /* @__PURE__ */ new Map(); function commandExists(command) { if (commandExistsCache.has(command)) return commandExistsCache.get(command); if (process.platform === "win32" && command === "explorer") { commandExistsCache.set(command, true); return true; } const checkCmd = process.platform === "win32" ? "where" : "which"; const result = spawnSync(checkCmd, [command], { stdio: "ignore" }); const exists = result.status === 0; commandExistsCache.set(command, exists); return exists; } function canOpenExplorer() { if (process.platform === "win32") return commandExists("explorer"); if (process.platform === "darwin") return commandExists("open"); if (process.platform === "linux") { const hasDisplay = Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY); return hasDisplay && commandExists("xdg-open"); } return false; } function canChooseDirectoryDialog() { return process.platform === "win32" || process.platform === "darwin"; } function sanitizeFilePart(input, fallback) { const value = input.trim().replace(/[<>:"/\\|?*\x00-\x1f]/g, "_").replace(/\s+/g, "_").slice(0, 40); return value.length > 0 ? value : fallback; } function openDirectoryInExplorer(directoryPath) { try { const child = process.platform === "win32" ? spawn("explorer", [directoryPath], { detached: true, stdio: "ignore" }) : process.platform === "darwin" ? spawn("open", [directoryPath], { detached: true, stdio: "ignore" }) : spawn("xdg-open", [directoryPath], { detached: true, stdio: "ignore" }); child.unref(); return true; } catch { return false; } } function showDirectoryPicker(defaultPath) { return new Promise((resolvePicker) => { if (process.platform === "win32") { const defaultPathB64 = defaultPath ? Buffer.from(defaultPath).toString("base64") : ""; const psScript = ` Add-Type -AssemblyName System.Windows.Forms $form = New-Object System.Windows.Forms.Form $form.TopMost = $true $form.ShowInTaskbar = $false $form.WindowState = 'Minimized' $dialog = New-Object System.Windows.Forms.FolderBrowserDialog $dialog.Description = "Select Export Folder" ${defaultPathB64 ? `$dialog.SelectedPath = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String("${defaultPathB64}"))` : ""} $dialog.ShowNewFolderButton = $true