feat: migrate MCP server to public API v1

This commit is contained in:
waeckerlinfederowicz66 committed 2026-10-26 14:45:01 +08:00
1 parent 3ae58fa3fc
commit 53dbe7c838
10 files changed
+568 -386

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
name: Test
on:
push:
pull_request:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node: [20, 22]
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: ${{ matrix.node }}
cache: npm
- run: npm ci --ignore-scripts
- run: npm test
- run: npm pack --dry-run
+12
View File
@@ -0,0 +1,12 @@
# Changelog
## 0.2.0
- Migrate generation to public API v1 asynchronous jobs and credential-free WAV downloads.
- Require a developer API key and Node.js 20.3 or newer.
- Retrieve voices from the API; add quota and job-status tools.
- Preserve original tool names and convert labelled dialogue to ordered speaker segments.
- Add bounded retries with stable UUIDs, Retry-After handling, operation deadlines, abort support and recovery IDs.
- Reject credential-bearing URLs, API redirects, malformed jobs, missing/deleted results and oversized or invalid audio.
- Save files privately without overwriting existing recordings; expand home-directory paths.
- Add unit and built-stdio tests without credit-consuming production requests.
+41 -103
View File
@@ -1,140 +1,78 @@
# FlowSpeech MCP Server
An [MCP (Model Context Protocol)](https://modelcontextprotocol.io) server that brings [FlowSpeech](https://flowspeech.io) text-to-speech capabilities directly into your LLM workflows.
Use [FlowSpeech](https://flowspeech.io) speech generation from an MCP client. Version 0.2.0 uses the public API v1: create a job, wait for completion, download the WAV, and save it locally.
## What is FlowSpeech?
## Requirements
[FlowSpeech](https://flowspeech.io) is a context-aware text-to-speech tool that converts text into human-like audio with:
- Node.js 20.3 or newer.
- A FlowSpeech developer API key from [API settings](https://flowspeech.io/settings/apikeys).
- Active paid Basic, Pro or Scale API access for generation. Trials do not include API access; generated speech consumes account credits.
- 🎭 **Emotion control** — stage directions like `***(say cheerfully: Hello!)***`
- ⏸️ **Pause control** — natural pacing and rhythm
- 🎙️ **30+ voices** — male and female voices across moods and styles
- 🌊 **Human-like output** — natural-sounding synthesis for creators, educators, and developers
## Installation
Run the MCP server directly with npx (no install needed):
```bash
npx mcp-flowspeech-server
```
Or install globally:
```bash
npm install -g mcp-flowspeech-server
```
Keep keys in your MCP client's local environment or secret manager, not in prompts or public configuration files.
## Configuration
Add to your MCP client config (e.g. Claude Desktop `claude_desktop_config.json`):
Set `FLOWSPEECH_API_KEY` in the environment used to launch your MCP client. A stdio configuration is:
```json
{
"mcpServers": {
"flowspeech": {
"command": "npx",
"args": ["-y", "mcp-flowspeech-server"],
"env": {
"FLOWSPEECH_OUTPUT_DIR": "~/flowspeech-audio"
}
"args": ["-y", "mcp-flowspeech-server@0.2.0"]
}
}
}
```
### Environment Variables
The MCP client must pass the key to its child process. Clients which do not inherit environment variables need a private `env` entry or their own secret configuration.
| Variable | Description | Default |
|---|---|---|
| `FLOWSPEECH_OUTPUT_DIR` | Directory to save generated audio files | `~/.flowspeech-mcp/audio` |
| Variable | Meaning | Default |
| --- | --- | --- |
| `FLOWSPEECH_API_KEY` | Required developer key | None |
| `FLOWSPEECH_OUTPUT_DIR` | Local output directory; `~/` is expanded | `~/.flowspeech-mcp/audio` |
| `FLOWSPEECH_TIMEOUT_MS` | Total operation deadline, at most 7200000 ms | `120000` |
| `FLOWSPEECH_BASE_URL` | Trusted API origin; never use an untrusted key recipient | `https://flowspeech.io` |
HTTP is allowed only for loopback test servers. Audio downloads never include the API key. Redirects are rejected, and audio downloads are limited to 100 MiB.
## Tools
### `flowspeech_tts`
| Tool | Arguments | Result |
| --- | --- | --- |
| `flowspeech_tts` | Required `text`; optional `voice` (Kore), `output_path`, `request_id` | Local WAV path, job ID, request ID and usage |
| `flowspeech_tts_multi` | Required labelled `text`; optional `voice_a` (Charon), `voice_b` (Kore), `output_path`, `request_id` | Local WAV path and recovery IDs |
| `flowspeech_list_voices` | Optional `gender`: male, female or all | Current authenticated voice catalog |
| `flowspeech_quota` | None | Available, remaining and reserved credits |
| `flowspeech_get_job` | Required UUID `job_id` | Current job status and result |
Convert text to speech with a single voice.
Two-speaker input uses one label per turn. Continuation lines belong to the preceding speaker:
**Parameters:**
| Name | Type | Required | Description |
|---|---|---|---|
| `text` | string | ✅ | Text to synthesize. Supports emotion stage directions. |
| `voice` | string | | Voice name (default: `Kore`) |
| `output_path` | string | | Custom file path for the output audio |
**Example:**
```
Convert this to audio with a cheerful tone using the Puck voice:
"***(say cheerfully: Good morning, everyone!)*** Today we're going to explore something amazing."
```text
Speaker1: Welcome.[pause:2s]Let's begin.
Speaker2: Thanks for inviting me.
Speaker1: What should we cover first?
```
### `flowspeech_tts_multi`
Output is 24 kHz, mono, 16-bit WAV. Existing output files are never overwritten. Anyone holding a result URL can download it, so keep job results and recordings private when appropriate.
Convert a two-speaker dialogue to speech.
## Recovery and migration
**Parameters:**
Version 0.1.0 called the retired synchronous endpoint. Upgrade the npm package and restart your MCP client. All tools now require a developer key, including voice listing. The three original tool names remain available, and two read-only tools have been added.
| Name | Type | Required | Description |
|---|---|---|---|
| `text` | string | ✅ | Dialogue with `Speaker1:` and `Speaker2:` prefixes |
| `voice_a` | string | | Voice for Speaker1 (default: `Charon`) |
| `voice_b` | string | | Voice for Speaker2 (default: `Kore`) |
| `output_path` | string | | Custom file path for the output audio |
The server submits `{ "input": ... }` with a UUID idempotency key, polls no faster than five seconds, honors a longer `Retry-After`, and downloads `result.url`. It does not expect an API Base64 response. Transient API retries preserve the exact request ID and body, with at most five attempts per API request.
**Example:**
For deliberate recovery, supply a UUID `request_id` and reuse it with unchanged text and voices. Errors include available recovery IDs. A timeout or client cancellation stops local waiting; it does not cancel a server-side job. Use `flowspeech_get_job` to inspect it. A failed download or local save should be retried from the existing result rather than starting another recording. A terminal failed/cancelled job requires a new UUID only when intentionally requesting a new attempt.
```
Speaker1: Welcome to the show! I'm your host.
Speaker2: And I'm your co-host. Great to be here!
Speaker1: Today we're talking about AI voices.
## Development and tests
```sh
npm ci --ignore-scripts
npm test
npm pack --dry-run
```
### `flowspeech_list_voices`
List all available voices.
**Parameters:**
| Name | Type | Description |
|---|---|---|
| `gender` | `male` \| `female` \| `all` | Filter by gender (default: `all`) |
## Available Voices
| Voice | Gender | Style |
|---|---|---|
| Puck | Male | Upbeat and playful |
| Fenrir | Male | Excitable and energetic |
| Zephyr | Female | Bright and cheerful |
| Autonoe | Female | Bright and welcoming |
| Charon | Male | Informative and narrative |
| Kore | Female | Firm and focused |
| Orus | Male | Firm and decisive |
| Iapetus | Male | Clear and professional |
| Umbriel | Male | Easy-going and relaxed |
| Callirrhoe | Female | Easy-going and soothing |
| Sulafat | Female | Warm and inviting |
| Achernar | Female | Soft and tender |
| Gacrux | Female | Mature and experienced |
| … | … | 30+ total — use `flowspeech_list_voices` to see all |
## Emotion Stage Directions
FlowSpeech supports inline emotion control using the format `***(say <style>: text)***`:
```
***(say cheerfully: Good morning!)***
Today I want to talk about something important.
***(say solemnly: This affects all of us.)***
```
## Links
- 🌐 Website: [https://flowspeech.io](https://flowspeech.io)
- 🎬 Demo: [https://youtu.be/nkBI7WbggW8](https://youtu.be/nkBI7WbggW8)
- 📦 npm: [https://www.npmjs.com/package/mcp-flowspeech-server](https://www.npmjs.com/package/mcp-flowspeech-server)
Tests cover mocked v1 contracts and a loopback HTTP server exercised through the built MCP stdio process. They do not call paid production generation. See [CHANGELOG.md](CHANGELOG.md) and the [public API documentation](https://flowspeech.io/docs/public-api).
## License
+5 -3
View File
@@ -21,7 +21,7 @@ FlowSpeech is a context-aware text-to-speech tool for human-like audio with emot
Install or run the FlowSpeech MCP server:
```bash
npx -y mcp-flowspeech-server
npx -y mcp-flowspeech-server@0.2.0
```
For MCP clients, configure the server with stdio transport:
@@ -31,7 +31,7 @@ For MCP clients, configure the server with stdio transport:
"mcpServers": {
"flowspeech": {
"command": "npx",
"args": ["-y", "mcp-flowspeech-server"],
"args": ["-y", "mcp-flowspeech-server@0.2.0"],
"env": {
"FLOWSPEECH_OUTPUT_DIR": "~/flowspeech-audio"
}
@@ -40,7 +40,7 @@ For MCP clients, configure the server with stdio transport:
}
```
Do not ask the user to paste secrets, account credentials, or private tokens into chat. If the user needs custom credentials for a private deployment, tell them to store those values in their local MCP client environment or secret manager.
Node.js 20.3 or newer and a FlowSpeech developer API key are required. Set FLOWSPEECH_API_KEY in the private environment passed to the MCP process. Generation requires active paid API access and consumes account credits. Do not ask the user to paste secrets, account credentials, or private tokens into chat; use the MCP client's environment or secret manager.
## Workflow
@@ -50,6 +50,8 @@ Do not ask the user to paste secrets, account credentials, or private tokens int
4. For two-speaker audio, call `flowspeech_tts_multi` with `Speaker1:` and `Speaker2:` labels.
5. Return the generated audio file path and briefly describe the voice choices used.
The server uses API v1 job creation, polling and separate WAV download. It does not send credentials to the audio host. Use flowspeech_quota for available credits and flowspeech_get_job for an existing UUID job. On timeout, recover using the reported request/job ID instead of creating duplicate work. A request_id UUID can be supplied and reused only with identical text and voices. Local cancellation does not cancel the server job, and existing output files are not overwritten.
## Emotion And Pause Control
FlowSpeech supports inline emotion cues with stage directions:
+3 -3
View File
@@ -1,12 +1,12 @@
{
"name": "mcp-flowspeech-server",
"version": "0.1.0",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mcp-flowspeech-server",
"version": "0.1.0",
"version": "0.2.0",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.0"
@@ -19,7 +19,7 @@
"typescript": "^5.0.0"
},
"engines": {
"node": ">=18"
"node": ">=20.3"
}
},
"node_modules/@hono/node-server": {
+13 -8
View File
@@ -1,7 +1,7 @@
{
"name": "mcp-flowspeech-server",
"version": "0.1.0",
"description": "MCP server for FlowSpeech — context-aware text-to-speech with emotion control, pause control, and 30+ voices",
"version": "0.2.0",
"description": "MCP server for the FlowSpeech public API with asynchronous speech jobs, voice discovery and quota checks",
"keywords": [
"mcp",
"mcp-server",
@@ -10,13 +10,15 @@
"flowspeech",
"ai",
"voice",
"speech-synthesis",
"n8n-community-node-package"
"speech-synthesis"
],
"homepage": "https://flowspeech.io",
"repository": {
"type": "git",
"url": "https://github.com/waeckerlinfederowicz66-sketch/mcp-flowspeech-server"
"url": "git+https://github.com/waeckerlinfederowicz66-sketch/mcp-flowspeech-server.git"
},
"publishConfig": {
"access": "public"
},
"license": "MIT",
"type": "module",
@@ -25,13 +27,16 @@
"mcp-flowspeech-server": "dist/index.js"
},
"files": [
"dist"
"dist",
"CHANGELOG.md",
"SKILL.md"
],
"scripts": {
"build": "tsc",
"test": "npm run build && node --test test/*.test.mjs",
"dev": "tsc --watch",
"start": "node dist/index.js",
"prepublishOnly": "npm run build"
"prepublishOnly": "npm test"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.0"
@@ -41,6 +46,6 @@
"typescript": "^5.0.0"
},
"engines": {
"node": ">=18"
"node": ">=20.3"
}
}
+204
View File
@@ -0,0 +1,204 @@
import { randomUUID } from 'node:crypto';
import { setTimeout as delay } from 'node:timers/promises';
export type SpeechInput =
| { mode: 'single'; text: string; voiceName: string }
| { mode: 'multi'; segments: { speaker: string; text: string; voiceName: string }[] };
export interface Job {
id: string;
requestId?: string;
status: string;
result?: { url: string; mimeType: string } | null;
resultDeleted?: boolean;
usage?: unknown;
error?: { code?: string };
}
export interface Voice { name: string; description: string; gender: string }
interface Context { deadline: number; requestId?: string; jobId?: string; signal?: AbortSignal }
interface Options {
apiKey: string;
baseUrl?: string;
timeoutMs?: number;
maxAudioBytes?: number;
fetch?: typeof fetch;
now?: () => number;
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
}
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const PENDING = new Set(['queued', 'running', 'merging', 'settling']);
const TERMINAL = new Set(['success', 'failed', 'cancelled']);
const TRANSIENT = new Set([429, 500, 502, 503, 504]);
function object(value: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid API response');
return value as Record<string, unknown>;
}
export function validateId(id: string): string {
if (!UUID.test(id)) throw new Error('Expected a UUID request or job ID');
return id;
}
function safeUrl(value: string, allowLocal: boolean): URL {
const url = new URL(value);
const local = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
if (url.username || url.password || url.hash || (url.protocol !== 'https:' && !(allowLocal && local && url.protocol === 'http:'))) throw new Error('Expected an HTTPS URL without embedded credentials');
return url;
}
function retryDelay(headers: Headers, fallback: number): number {
const value = headers.get('retry-after');
if (!value) return fallback;
const seconds = /^\d+(?:\.\d+)?$/.test(value) ? Number(value) : (Date.parse(value) - Date.now()) / 1000;
return Number.isFinite(seconds) ? Math.max(fallback, seconds * 1000) : fallback;
}
async function boundedBody(response: Response, maximum: number): Promise<Uint8Array> {
if (Number(response.headers.get('content-length')) > maximum) {
await response.body?.cancel();
throw new Error('Response exceeds configured size limit');
}
const reader = response.body?.getReader();
if (!reader) throw new Error('Empty response body');
const chunks: Uint8Array[] = [];
let length = 0;
try {
for (;;) {
const part = await reader.read();
if (part.done) break;
length += part.value.length;
if (length > maximum) throw new Error('Response exceeds configured size limit');
chunks.push(part.value);
}
} finally { await reader.cancel().catch(() => {}); }
return Buffer.concat(chunks, length);
}
export function dialogueInput(text: string, voiceA: string, voiceB: string): SpeechInput {
const segments: { speaker: string; text: string; voiceName: string }[] = [];
for (const line of text.split(/\r?\n/)) {
const match = /^(Speaker[12]):\s*(.*)$/.exec(line.trim());
if (match) segments.push({ speaker: match[1], text: match[2], voiceName: match[1] === 'Speaker1' ? voiceA : voiceB });
else if (line.trim() && segments.length) segments[segments.length - 1].text += '\n' + line;
else if (line.trim()) throw new Error('Dialogue must start with Speaker1: or Speaker2:');
}
if (!segments.length || segments.some(segment => !segment.text.trim())) throw new Error('Every speaker turn needs spoken text');
return { mode: 'multi', segments };
}
export class FlowSpeechClient {
private readonly baseUrl: string;
private readonly timeoutMs: number;
private readonly maxAudioBytes: number;
private readonly fetcher: typeof fetch;
private readonly now: () => number;
private readonly sleep: (ms: number, signal?: AbortSignal) => Promise<void>;
constructor(private readonly options: Options) {
if (!options.apiKey?.trim() || /[\r\n]/.test(options.apiKey)) throw new Error('Set FLOWSPEECH_API_KEY to a developer API key');
const base = safeUrl(options.baseUrl ?? 'https://flowspeech.io', true);
if (base.pathname !== '/' || base.search) throw new Error('API base URL must be an origin without a path or query');
this.baseUrl = base.origin;
this.timeoutMs = options.timeoutMs ?? 120_000;
this.maxAudioBytes = options.maxAudioBytes ?? 100 * 1024 * 1024;
if (!Number.isFinite(this.timeoutMs) || this.timeoutMs < 1 || this.timeoutMs > 7_200_000) throw new Error('Timeout must be between 1 and 7200000 milliseconds');
if (!Number.isSafeInteger(this.maxAudioBytes) || this.maxAudioBytes < 1) throw new Error('Invalid audio size limit');
this.fetcher = options.fetch ?? fetch;
this.now = options.now ?? (() => performance.now());
this.sleep = options.sleep ?? (async (ms, signal) => { await delay(ms, undefined, { signal }); });
}
private context(signal?: AbortSignal, requestId?: string): Context {
return { deadline: this.now() + this.timeoutMs, signal, requestId };
}
private remaining(context: Context): number {
if (context.signal?.aborted) throw new Error('Operation stopped; this does not cancel the server job');
const remaining = context.deadline - this.now();
if (remaining <= 0) throw new Error('Operation timed out; this does not cancel the server job');
return remaining;
}
private async pause(ms: number, context: Context): Promise<void> {
if (ms >= this.remaining(context)) throw new Error('Required wait exceeds the timeout; resume the existing request or job later');
await this.sleep(ms, context.signal);
this.remaining(context);
}
private async api(path: string, context: Context, body?: unknown): Promise<{ data: Record<string, unknown>; headers: Headers }> {
const encoded = body === undefined ? undefined : JSON.stringify(body);
for (let attempt = 0; attempt < 5; attempt++) {
this.remaining(context);
let response: Response;
let raw: Uint8Array;
try {
const timeout = AbortSignal.timeout(Math.max(1, Math.ceil(Math.min(30_000, this.remaining(context)))));
response = await this.fetcher(this.baseUrl + path, {
method: body === undefined ? 'GET' : 'POST', redirect: 'error', credentials: 'omit',
signal: context.signal ? AbortSignal.any([timeout, context.signal]) : timeout,
headers: {
Authorization: `Bearer ${this.options.apiKey}`,
...(encoded === undefined ? {} : { 'Content-Type': 'application/json', 'Idempotency-Key': context.requestId! }),
}, body: encoded,
});
raw = await boundedBody(response, 1024 * 1024);
} catch {
this.remaining(context);
if (attempt === 4) throw new Error('API transport failed after bounded retries');
await this.pause(1000 * 2 ** attempt, context);
continue;
}
let data: Record<string, unknown> | undefined;
try { data = object(JSON.parse(new TextDecoder().decode(raw))); } catch { /* Error responses may not be JSON. */ }
if (response.ok) {
if (!data) throw new Error('API returned invalid JSON');
return { data, headers: response.headers };
}
const error = data?.error && typeof data.error === 'object' ? data.error as Record<string, unknown> : {};
if (TRANSIENT.has(response.status) && error.retryable !== false && attempt < 4) {
await this.pause(retryDelay(response.headers, 1000 * 2 ** attempt), context);
continue;
}
const code = typeof error.code === 'string' && /^[a-zA-Z0-9_]{1,100}$/.test(error.code) ? error.code : 'api_error';
throw new Error(`FlowSpeech HTTP ${response.status}: ${code}`);
}
throw new Error('API retry budget exhausted');
}
private job(data: Record<string, unknown>): Job {
if (typeof data.id !== 'string' || !UUID.test(data.id) || typeof data.status !== 'string' || (!PENDING.has(data.status) && !TERMINAL.has(data.status))) throw new Error('API returned an invalid job ID or status');
return data as unknown as Job;
}
async quota(signal?: AbortSignal): Promise<Record<string, unknown>> {
return (await this.api('/api/v1/quota', this.context(signal))).data;
}
async voices(signal?: AbortSignal): Promise<Voice[]> {
const data = (await this.api('/api/v1/voices', this.context(signal))).data;
if (!Array.isArray(data.voices) || data.voices.some(v => !v || typeof v.name !== 'string' || typeof v.description !== 'string' || typeof v.gender !== 'string')) throw new Error('Invalid voices response');
return data.voices as Voice[];
}
async getJob(id: string, signal?: AbortSignal): Promise<Job> {
return this.job((await this.api(`/api/v1/tts/jobs/${validateId(id)}`, this.context(signal))).data);
}
async generate(input: SpeechInput, requestId: string = randomUUID(), signal?: AbortSignal): Promise<{ job: Job; audio: Uint8Array; requestId: string }> {
validateId(requestId);
const context = this.context(signal, requestId);
try {
const turns = input.mode === 'single' ? [input] : input.segments;
if (!turns?.length || turns.length > 1000 || turns.some(t => !t.text?.trim() || !t.voiceName?.trim()) || turns.reduce((n, t) => n + t.text.length, 0) > 100_000) throw new Error('Invalid speech text, voice or input limits');
if (Buffer.byteLength(JSON.stringify({ input })) > 1024 * 1024) throw new Error('Input exceeds the JSON body limit');
let result = await this.api('/api/v1/tts/jobs', context, { input });
let job = this.job(result.data);
context.jobId = job.id;
while (PENDING.has(job.status)) {
await this.pause(retryDelay(result.headers, 5000), context);
result = await this.api(`/api/v1/tts/jobs/${job.id}`, context);
job = this.job(result.data);
if (job.id !== context.jobId) throw new Error('Status response changed the job ID');
}
if (job.status !== 'success') throw new Error(`Generation ${job.status}`);
if (!job.result || typeof job.result.url !== 'string') throw new Error(job.resultDeleted ? 'Audio result was deleted' : 'Completed job has no audio result');
if (job.result.mimeType !== 'audio/wav') throw new Error('Unexpected audio format; expected audio/wav');
const url = safeUrl(job.result.url, new URL(this.baseUrl).protocol === 'http:');
// Download independently, without API credentials or automatic redirects.
const timeout = AbortSignal.timeout(Math.max(1, Math.ceil(Math.min(120_000, this.remaining(context)))));
const audioResponse = await this.fetcher(url.toString(), { redirect: 'error', credentials: 'omit', signal: signal ? AbortSignal.any([timeout, signal]) : timeout });
if (!audioResponse.ok) throw new Error(`Audio download HTTP ${audioResponse.status}; retry this download, not generation`);
const audio = await boundedBody(audioResponse, this.maxAudioBytes);
if (audio.length < 12 || Buffer.from(audio.subarray(0, 4)).toString() !== 'RIFF' || Buffer.from(audio.subarray(8, 12)).toString() !== 'WAVE') throw new Error('Downloaded result is not a WAV file');
return { job, audio, requestId };
} catch (error) {
const message = error instanceof Error ? error.message : 'Operation failed';
throw new Error(`${message.split(this.options.apiKey).join('[redacted]')}; requestId=${requestId}${context.jobId ? `; jobId=${context.jobId}` : ''}`);
}
}
}
+71 -269
View File
@@ -1,281 +1,83 @@
#!/usr/bin/env node
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
Tool,
} from '@modelcontextprotocol/sdk/types.js';
import fs from 'fs';
import path from 'path';
import os from 'os';
import { CallToolRequestSchema, ListToolsRequestSchema, type Tool } from '@modelcontextprotocol/sdk/types.js';
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import { FlowSpeechClient, dialogueInput } from './client.js';
const FLOWSPEECH_API = 'https://flowspeech.io/api/ai/text-to-speech';
const DEFAULT_OUTPUT_DIR = process.env.FLOWSPEECH_OUTPUT_DIR
|| path.join(os.homedir(), '.flowspeech-mcp', 'audio');
const VOICES = [
// Bright & Energetic
{ name: 'Puck', description: 'Upbeat and playful', gender: 'male' },
{ name: 'Fenrir', description: 'Excitable and energetic', gender: 'male' },
{ name: 'Zephyr', description: 'Bright and cheerful', gender: 'female' },
{ name: 'Autonoe', description: 'Bright and welcoming', gender: 'female' },
{ name: 'Laomedeia', description: 'Upbeat and lively', gender: 'female' },
{ name: 'Sadachbia', description: 'Lively and colorful', gender: 'male' },
{ name: 'Leda', description: 'Youthful and casual', gender: 'female' },
{ name: 'Aoede', description: 'Breezy and light', gender: 'female' },
// Clear & Informative
{ name: 'Charon', description: 'Informative and narrative', gender: 'male' },
{ name: 'Rasalgethi', description: 'Informative and educational', gender: 'male' },
{ name: 'Kore', description: 'Firm and focused', gender: 'female' },
{ name: 'Orus', description: 'Firm and decisive', gender: 'male' },
{ name: 'Alnilam', description: 'Firm and authoritative', gender: 'male' },
{ name: 'Erinome', description: 'Clear and precise', gender: 'female' },
{ name: 'Iapetus', description: 'Clear and professional', gender: 'male' },
{ name: 'Schedar', description: 'Even and steady', gender: 'male' },
{ name: 'Pulcherrima', description: 'Forward and direct', gender: 'male' },
{ name: 'Sadaltager', description: 'Knowledgeable and informative',gender: 'male' },
// Calm & Relaxed
{ name: 'Umbriel', description: 'Easy-going and relaxed', gender: 'male' },
{ name: 'Callirrhoe', description: 'Easy-going and soothing', gender: 'female' },
{ name: 'Achird', description: 'Friendly and helpful', gender: 'male' },
{ name: 'Zubenelgenubi', description: 'Casual and laid-back', gender: 'male' },
{ name: 'Algieba', description: 'Smooth and mellow', gender: 'male' },
{ name: 'Despina', description: 'Smooth and reassuring', gender: 'female' },
// Warm & Distinctive
{ name: 'Enceladus', description: 'Breathy and magnetic', gender: 'male' },
{ name: 'Algenib', description: 'Gravelly and textured', gender: 'male' },
{ name: 'Sulafat', description: 'Warm and inviting', gender: 'female' },
{ name: 'Vindemiatrix',description: 'Gentle and caring', gender: 'female' },
{ name: 'Achernar', description: 'Soft and tender', gender: 'female' },
{ name: 'Gacrux', description: 'Mature and experienced', gender: 'female' },
];
function ensureOutputDir(dir: string): void {
fs.mkdirSync(dir, { recursive: true });
const VERSION = '0.2.0';
const outputDirectory = expandHome(process.env.FLOWSPEECH_OUTPUT_DIR || '~/.flowspeech-mcp/audio');
function expandHome(value: string): string {
return value === '~' ? os.homedir() : value.startsWith('~/') ? path.join(os.homedir(), value.slice(2)) : path.resolve(value);
}
interface TtsApiResponse {
code: number;
message?: string;
data?: {
mimeType: string;
audioBase64: string;
sampleRate: number;
numChannels: number;
bitsPerSample: number;
quota?: unknown;
};
function stringArg(args: Record<string, unknown>, key: string, fallback?: string): string {
const value = args[key] ?? fallback;
if (typeof value !== 'string' || !value.trim()) throw new Error(`${key} must be a nonempty string`);
return value;
}
async function callFlowSpeechTts(
text: string,
voiceName: string = 'Kore',
speakerB?: string,
): Promise<TtsApiResponse> {
let speakers: Array<{ voiceName: string; speaker?: string }>;
if (speakerB) {
// Multi-speaker mode: mark text with Speaker1/Speaker2
speakers = [
{ speaker: 'Speaker1', voiceName },
{ speaker: 'Speaker2', voiceName: speakerB },
];
} else {
speakers = [{ voiceName }];
}
const response = await fetch(FLOWSPEECH_API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, speakers }),
function client(): FlowSpeechClient {
return new FlowSpeechClient({
apiKey: process.env.FLOWSPEECH_API_KEY || '', baseUrl: process.env.FLOWSPEECH_BASE_URL,
timeoutMs: process.env.FLOWSPEECH_TIMEOUT_MS ? Number(process.env.FLOWSPEECH_TIMEOUT_MS) : undefined,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`FlowSpeech API error (${response.status}): ${errorText}`);
}
return response.json() as Promise<TtsApiResponse>;
}
function base64ToBuffer(base64: string): Buffer {
return Buffer.from(base64, 'base64');
}
function getMimeExtension(mimeType: string): string {
if (mimeType.includes('wav') || mimeType.includes('L16') || mimeType.includes('l16')) return 'wav';
if (mimeType.includes('mp3') || mimeType.includes('mpeg')) return 'mp3';
if (mimeType.includes('ogg')) return 'ogg';
if (mimeType.includes('flac')) return 'flac';
return 'wav';
}
const TOOLS: Tool[] = [
{
name: 'flowspeech_tts',
description:
'Convert text to speech using FlowSpeech — a context-aware TTS engine with emotion control and 30+ voices. Returns the file path of the generated audio.',
inputSchema: {
type: 'object',
properties: {
text: {
type: 'string',
description: 'Text to convert to speech. Supports emotion stage directions inside ***(say <style>: text)***: e.g. "***(say cheerfully: Hello!)***"',
},
voice: {
type: 'string',
description: `Voice name to use. Default: "Kore". Available: ${VOICES.map((v) => v.name).join(', ')}`,
default: 'Kore',
},
output_path: {
type: 'string',
description: `File path to save audio. Defaults to a timestamped file in ${DEFAULT_OUTPUT_DIR}`,
},
},
required: ['text'],
},
},
{
name: 'flowspeech_tts_multi',
description:
'Convert a two-speaker dialogue to speech using FlowSpeech. Assign **Speaker1:** and **Speaker2:** prefixes in the text.',
inputSchema: {
type: 'object',
properties: {
text: {
type: 'string',
description:
'Dialogue text with speaker labels. Example:\n"Speaker1: Good morning!\nSpeaker2: Good morning! How are you?"',
},
voice_a: {
type: 'string',
description: 'Voice for Speaker1. Default: "Charon"',
default: 'Charon',
},
voice_b: {
type: 'string',
description: 'Voice for Speaker2. Default: "Kore"',
default: 'Kore',
},
output_path: {
type: 'string',
description: `File path to save audio. Defaults to a timestamped file in ${DEFAULT_OUTPUT_DIR}`,
},
},
required: ['text'],
},
},
{
name: 'flowspeech_list_voices',
description: 'List all available FlowSpeech voices with their descriptions and gender.',
inputSchema: {
type: 'object',
properties: {
gender: {
type: 'string',
enum: ['male', 'female', 'all'],
description: 'Filter by gender. Default: "all"',
default: 'all',
},
},
},
},
const outputProperties = {
output_path: { type: 'string', description: 'Optional output WAV file path. An existing file is not overwritten.' },
request_id: { type: 'string', description: 'Optional UUID for retrying the same text and voice without creating duplicate work.' },
};
const tools: Tool[] = [
{ name: 'flowspeech_tts', description: 'Generate a WAV recording with FlowSpeech. Requires an API key and paid API access; consumes account credits.', inputSchema: {
type: 'object', properties: { text: { type: 'string' }, voice: { type: 'string', default: 'Kore' }, ...outputProperties }, required: ['text'],
} },
{ name: 'flowspeech_tts_multi', description: 'Generate a two-speaker WAV recording. Each turn starts with Speaker1: or Speaker2:. Consumes account credits.', inputSchema: {
type: 'object', properties: { text: { type: 'string' }, voice_a: { type: 'string', default: 'Charon' }, voice_b: { type: 'string', default: 'Kore' }, ...outputProperties }, required: ['text'],
} },
{ name: 'flowspeech_list_voices', description: 'Retrieve the current voice catalog from FlowSpeech.', inputSchema: {
type: 'object', properties: { gender: { type: 'string', enum: ['male', 'female', 'all'], default: 'all' } },
} },
{ name: 'flowspeech_quota', description: 'Read available, remaining and reserved API credits.', inputSchema: { type: 'object', properties: {} } },
{ name: 'flowspeech_get_job', description: 'Read an existing job by UUID without creating a new recording.', inputSchema: {
type: 'object', properties: { job_id: { type: 'string' } }, required: ['job_id'],
} },
];
const server = new Server(
{ name: 'flowspeech', version: '0.1.0' },
{ capabilities: { tools: {} } },
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const server = new Server({ name: 'flowspeech', version: VERSION }, { capabilities: { tools: {} } });
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
const { name, arguments: args = {} } = request.params;
if (name === 'flowspeech_list_voices') {
const gender = (args.gender as string) || 'all';
const filtered = gender === 'all'
? VOICES
: VOICES.filter((v) => v.gender === gender);
const rows = filtered.map(
(v) => `• **${v.name}** (${v.gender}) — ${v.description}`,
);
return {
content: [
{
type: 'text',
text: `FlowSpeech has ${filtered.length} voice(s):\n\n${rows.join('\n')}\n\nLearn more: https://flowspeech.io`,
},
],
};
try {
const api = client();
let text: string;
if (name === 'flowspeech_list_voices') {
const gender = stringArg(args, 'gender', 'all');
if (!['male', 'female', 'all'].includes(gender)) throw new Error('Invalid gender filter');
text = JSON.stringify((await api.voices(extra.signal)).filter(v => gender === 'all' || v.gender === gender), null, 2);
} else if (name === 'flowspeech_quota') {
text = JSON.stringify(await api.quota(extra.signal), null, 2);
} else if (name === 'flowspeech_get_job') {
text = JSON.stringify(await api.getJob(stringArg(args, 'job_id'), extra.signal), null, 2);
} else if (name === 'flowspeech_tts' || name === 'flowspeech_tts_multi') {
const script = stringArg(args, 'text');
const input = name === 'flowspeech_tts'
? { mode: 'single' as const, text: script, voiceName: stringArg(args, 'voice', 'Kore') }
: dialogueInput(script, stringArg(args, 'voice_a', 'Charon'), stringArg(args, 'voice_b', 'Kore'));
const requestId = args.request_id === undefined ? undefined : stringArg(args, 'request_id');
const result = await api.generate(input, requestId, extra.signal);
const file = args.output_path === undefined
? path.join(outputDirectory, `flowspeech-${result.job.id}.wav`)
: expandHome(stringArg(args, 'output_path'));
try {
await mkdir(path.dirname(file), { recursive: true });
await writeFile(file, result.audio, { flag: 'wx', mode: 0o600 });
} catch {
throw new Error(`Cannot save audio without overwriting an existing file. Retrieve jobId=${result.job.id}; requestId=${result.requestId}. Do not create another recording.`);
}
text = `Audio saved to: ${file}\nFormat: audio/wav\nJob ID: ${result.job.id}\nRequest ID: ${result.requestId}\nUsage: ${JSON.stringify(result.job.usage ?? {})}`;
} else throw new Error(`Unknown tool: ${name}`);
return { content: [{ type: 'text', text }] };
} catch (error) {
return { isError: true, content: [{ type: 'text', text: error instanceof Error ? error.message : 'FlowSpeech operation failed' }] };
}
if (name === 'flowspeech_tts') {
const text = args.text as string;
const voice = (args.voice as string) || 'Kore';
const outputPath = (args.output_path as string) || null;
if (!text) throw new Error('text is required');
const result = await callFlowSpeechTts(text, voice);
if (result.code !== 0 || !result.data?.audioBase64) {
throw new Error(result.message || 'FlowSpeech TTS failed');
}
const ext = getMimeExtension(result.data.mimeType);
const outDir = outputPath
? path.dirname(outputPath)
: DEFAULT_OUTPUT_DIR;
ensureOutputDir(outDir);
const filePath = outputPath || path.join(DEFAULT_OUTPUT_DIR, `flowspeech-${Date.now()}.${ext}`);
fs.writeFileSync(filePath, base64ToBuffer(result.data.audioBase64));
return {
content: [
{
type: 'text',
text: `Audio saved to: ${filePath}\nVoice: ${voice}\nFormat: ${result.data.mimeType}\nPowered by FlowSpeech (https://flowspeech.io)`,
},
],
};
}
if (name === 'flowspeech_tts_multi') {
const text = args.text as string;
const voiceA = (args.voice_a as string) || 'Charon';
const voiceB = (args.voice_b as string) || 'Kore';
const outputPath = (args.output_path as string) || null;
if (!text) throw new Error('text is required');
const result = await callFlowSpeechTts(text, voiceA, voiceB);
if (result.code !== 0 || !result.data?.audioBase64) {
throw new Error(result.message || 'FlowSpeech multi-speaker TTS failed');
}
const ext = getMimeExtension(result.data.mimeType);
const outDir = outputPath ? path.dirname(outputPath) : DEFAULT_OUTPUT_DIR;
ensureOutputDir(outDir);
const filePath = outputPath || path.join(DEFAULT_OUTPUT_DIR, `flowspeech-multi-${Date.now()}.${ext}`);
fs.writeFileSync(filePath, base64ToBuffer(result.data.audioBase64));
return {
content: [
{
type: 'text',
text: `Audio saved to: ${filePath}\nSpeaker1: ${voiceA} | Speaker2: ${voiceB}\nFormat: ${result.data.mimeType}\nPowered by FlowSpeech (https://flowspeech.io)`,
},
],
};
}
throw new Error(`Unknown tool: ${name}`);
});
const transport = new StdioServerTransport();
await server.connect(transport);
await server.connect(new StdioServerTransport());
+147
View File
@@ -0,0 +1,147 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { FlowSpeechClient, dialogueInput } from '../dist/client.js';
const id = 'b698824b-de05-486d-8833-05d5c4522a39';
const requestId = '51382b81-9d36-49a5-994e-9b67686c6bd1';
const input = { mode: 'single', text: 'Hello', voiceName: 'Kore' };
const wav = Buffer.from('RIFF\x24\x00\x00\x00WAVEfmt ');
const done = { id, status: 'success', result: { url: 'https://audio.example.test/result.wav', mimeType: 'audio/wav' } };
const json = (body, status = 200, headers = {}) => new Response(JSON.stringify(body), { status, headers });
function harness(steps, options = {}) {
const calls = [], sleeps = [];
let now = 0;
const client = new FlowSpeechClient({ apiKey: 'test-key', now: () => now,
sleep: async ms => { sleeps.push(ms); now += ms; },
fetch: async (url, init) => {
calls.push({ url: String(url), init });
const next = steps.shift();
if (next instanceof Error) throw next;
assert.ok(next, 'unexpected request');
return next;
}, ...options });
return { client, calls, sleeps };
}
test('single create, queued polling, Retry-After and credential-free WAV download', async () => {
const h = harness([json({ id, status: 'queued' }, 202, { 'Retry-After': '8' }), json(done), new Response(wav)]);
const result = await h.client.generate(input, requestId);
assert.deepEqual(result.audio, wav);
assert.equal(result.requestId, requestId);
assert.deepEqual(h.sleeps, [8000]);
assert.equal(h.calls[0].url, 'https://flowspeech.io/api/v1/tts/jobs');
assert.deepEqual(JSON.parse(h.calls[0].init.body), { input });
assert.equal(h.calls[0].init.headers.Authorization, 'Bearer test-key');
assert.equal(h.calls[0].init.headers['Idempotency-Key'], requestId);
assert.equal(h.calls[1].url, `https://flowspeech.io/api/v1/tts/jobs/${id}`);
assert.equal(h.calls[2].init.headers, undefined);
assert.ok(h.calls.every(c => c.init.redirect === 'error' && c.init.credentials === 'omit'));
});
test('network retry preserves body and UUID', async () => {
const h = harness([new Error('network'), json(done), new Response(wav)]);
await h.client.generate(input, requestId);
assert.equal(h.calls[0].init.body, h.calls[1].init.body);
assert.equal(h.calls[0].init.headers['Idempotency-Key'], h.calls[1].init.headers['Idempotency-Key']);
assert.deepEqual(h.sleeps, [1000]);
});
test('429 respects Retry-After and reuses request ID', async () => {
const h = harness([json({ error: { code: 'rate_limited', retryable: true } }, 429, { 'Retry-After': '9' }), json(done), new Response(wav)]);
await h.client.generate(input, requestId);
assert.deepEqual(h.sleeps, [9000]);
assert.equal(h.calls[0].init.body, h.calls[1].init.body);
});
test('HTTP-date Retry-After never polls earlier than five seconds', async () => {
const h = harness([json({ id, status: 'queued' }, 202, { 'Retry-After': new Date(Date.now() + 15000).toUTCString() }), json(done), new Response(wav)]);
await h.client.generate(input, requestId);
assert.ok(h.sleeps[0] >= 13000);
});
test('multi input preserves turn order and continuation lines', async () => {
const multi = dialogueInput('Speaker1: Hello\nMore text\nSpeaker2: Hi\nSpeaker1: Bye', 'Charon', 'Kore');
assert.deepEqual(multi.segments.map(s => s.voiceName), ['Charon', 'Kore', 'Charon']);
assert.equal(multi.segments[0].text, 'Hello\nMore text');
const h = harness([json(done), new Response(wav)]);
await h.client.generate(multi, requestId);
assert.deepEqual(JSON.parse(h.calls[0].init.body), { input: multi });
assert.throws(() => dialogueInput('unlabelled', 'Kore', 'Charon'));
assert.throws(() => dialogueInput('Speaker1:', 'Kore', 'Charon'));
});
for (const status of ['failed', 'cancelled']) test(`${status} is not successful even with HTTP 200`, async () => {
const h = harness([json({ id, status })]);
await assert.rejects(h.client.generate(input, requestId), new RegExp(`Generation ${status}.*jobId=${id}`));
assert.equal(h.calls.length, 1);
});
for (const [name, body] of [
['deleted', { ...done, result: null, resultDeleted: true }],
['missing', { ...done, result: null }],
['unknown status', { id, status: 'complete' }],
['missing ID', { status: 'success' }],
]) test(`rejects ${name} result`, async () => {
const h = harness([json(body)]);
await assert.rejects(h.client.generate(input, requestId));
assert.equal(h.calls.length, 1);
});
test('deadline stops before a required wait and retains recovery IDs', async () => {
const h = harness([json({ id, status: 'queued' }, 202)], { timeoutMs: 4000 });
await assert.rejects(h.client.generate(input, requestId), new RegExp(`timeout.*requestId=${requestId}.*jobId=${id}`));
assert.equal(h.calls.length, 1);
});
test('nonretryable errors never resubmit, hide raw server messages and secrets', async () => {
const h = harness([json({ error: { code: 'subscription_required', retryable: false, message: 'test-key private script' } }, 503)]);
await assert.rejects(h.client.generate(input, requestId), error => {
assert.match(error.message, /HTTP 503: subscription_required/);
assert.ok(!error.message.includes('test-key') && !error.message.includes('private script'));
return true;
});
assert.equal(h.calls.length, 1);
});
test('download failures do not create another job', async () => {
const h = harness([json(done), new Response('unavailable', { status: 503 })]);
await assert.rejects(h.client.generate(input, requestId), /retry this download, not generation/);
assert.equal(h.calls.length, 2);
});
test('bounded download rejects oversized and non-WAV payloads', async () => {
const h = harness([json(done), new Response(wav)], { maxAudioBytes: 8 });
await assert.rejects(h.client.generate(input, requestId), /size limit/);
const bad = harness([json(done), new Response('not an audio file')]);
await assert.rejects(bad.client.generate(input, requestId), /not a WAV/);
});
test('rejects insecure or credential-bearing result URLs before sending a request', async () => {
for (const url of ['http://example.com/a.wav', 'https://user:password@example.com/a.wav', 'file:///tmp/audio.wav']) {
const h = harness([json({ ...done, result: { ...done.result, url } })]);
await assert.rejects(h.client.generate(input, requestId));
assert.equal(h.calls.length, 1);
}
});
test('quota, voices and job lookup use authenticated v1 reads', async () => {
const h = harness([json({ quota: { available: 15 } }), json({ voices: [{ name: 'Kore', gender: 'female', description: 'Firm' }] }), json(done)]);
assert.equal((await h.client.quota()).quota.available, 15);
assert.equal((await h.client.voices())[0].name, 'Kore');
assert.equal((await h.client.getJob(id)).id, id);
assert.deepEqual(h.calls.map(c => new URL(c.url).pathname), ['/api/v1/quota', '/api/v1/voices', `/api/v1/tts/jobs/${id}`]);
assert.ok(h.calls.every(c => c.init.method === 'GET' && c.init.headers.Authorization === 'Bearer test-key'));
});
test('invalid configuration, UUID and input fail without requests', async () => {
assert.throws(() => new FlowSpeechClient({ apiKey: '' }));
assert.throws(() => new FlowSpeechClient({ apiKey: 'key', baseUrl: 'https://user:pass@example.com' }));
const h = harness([]);
await assert.rejects(h.client.getJob('../quota'));
await assert.rejects(h.client.generate(input, 'bad-id'));
await assert.rejects(h.client.generate({ ...input, text: ' ' }, requestId));
assert.equal(h.calls.length, 0);
});
test('aborted operations never start generation', async () => {
const h = harness([]);
await assert.rejects(h.client.generate(input, requestId, AbortSignal.abort()), /stopped/);
assert.equal(h.calls.length, 0);
});
test('retry exhaustion is bounded', async () => {
const h = harness(Array.from({ length: 5 }, () => new Error('network')));
await assert.rejects(h.client.generate(input, requestId), /bounded retries/);
assert.equal(h.calls.length, 5);
});
test('invalid JSON and changed job IDs are rejected', async () => {
const h = harness([new Response('not JSON')]);
await assert.rejects(h.client.generate(input, requestId), /invalid JSON/);
const changed = harness([json({ id, status: 'queued' }, 202), json({ ...done, id: requestId })]);
await assert.rejects(changed.client.generate(input, requestId), /changed the job ID/);
});
+51
View File
@@ -0,0 +1,51 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { once } from 'node:events';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
test('built stdio server submits v1, saves WAV and refuses overwrites', async () => {
const directory = await mkdtemp(path.join(tmpdir(), 'flowspeech-mcp-test-'));
const id = 'b698824b-de05-486d-8833-05d5c4522a39';
const requestId = '51382b81-9d36-49a5-994e-9b67686c6bd1';
const wav = Buffer.from('RIFF\x24\0\0\0WAVEfmt ');
const requests = [];
const server = createServer(async (req, res) => {
requests.push({ path: req.url, auth: req.headers.authorization });
if (req.url === '/audio.wav') { res.end(wav); return; }
assert.equal(req.headers.authorization, 'Bearer fake-test-key');
res.setHeader('Content-Type', 'application/json');
if (req.url === '/api/v1/voices') { res.end(JSON.stringify({ voices: [{ name: 'Kore', gender: 'female', description: 'Firm' }] })); return; }
if (req.url !== '/api/v1/tts/jobs') { res.writeHead(404).end('{}'); return; }
assert.equal(req.headers['idempotency-key'], requestId);
let body = ''; for await (const chunk of req) body += chunk;
assert.deepEqual(JSON.parse(body), { input: { mode: 'single', text: 'Hello', voiceName: 'Kore' } });
res.writeHead(202).end(JSON.stringify({ id, status: 'success', result: { url: `http://127.0.0.1:${server.address().port}/audio.wav`, mimeType: 'audio/wav' } }));
});
server.listen(0, '127.0.0.1'); await once(server, 'listening');
const client = new Client({ name: 'test', version: '1.0.0' });
try {
await client.connect(new StdioClientTransport({ command: process.execPath, args: ['dist/index.js'], env: {
...process.env, FLOWSPEECH_API_KEY: 'fake-test-key', FLOWSPEECH_BASE_URL: `http://127.0.0.1:${server.address().port}`, FLOWSPEECH_OUTPUT_DIR: directory,
} }));
assert.equal((await client.listTools()).tools.length, 5);
const voices = await client.callTool({ name: 'flowspeech_list_voices', arguments: { gender: 'female' } });
assert.match(voices.content[0].text, /Kore/);
const args = { name: 'flowspeech_tts', arguments: { text: 'Hello', request_id: requestId } };
const result = await client.callTool(args);
assert.ok(!result.isError, JSON.stringify(result));
assert.deepEqual(await readFile(path.join(directory, `flowspeech-${id}.wav`)), wav);
const repeat = await client.callTool(args);
assert.equal(repeat.isError, true);
assert.match(repeat.content[0].text, /without overwriting/);
assert.ok(requests.filter(r => r.path === '/audio.wav').every(r => r.auth === undefined));
assert.ok(requests.every(r => !r.path.includes('/api/ai/')));
} finally {
await client.close(); server.closeAllConnections(); await new Promise(resolve => server.close(resolve));
await rm(directory, { recursive: true, force: true });
}
});