@kajidog/mcp-tts-voicevox
Version:
VOICEVOX integration for MCP - Text-to-Speech server using VOICEVOX engine
1,635 lines (1,613 loc) • 117 kB
JavaScript
#!/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