# Directory Structure
```
├── .github
│ └── workflows
│ └── npm-publish.yml
├── .gitignore
├── assets
│ └── banner.png
├── CHANGELOG.md
├── LICENSE
├── package-lock.json
├── package.json
├── pnpm-lock.yaml
├── README.md
├── README.zh-CN.md
├── src
│ ├── config
│ │ ├── config-manager.ts
│ │ ├── constants.ts
│ │ └── schemas.ts
│ ├── core
│ │ ├── base-client.ts
│ │ ├── rate-limiter.ts
│ │ └── task-manager.ts
│ ├── index.ts
│ ├── services
│ │ ├── image-service.ts
│ │ └── tts-service.ts
│ └── utils
│ ├── error-handler.ts
│ └── file-handler.ts
└── tsconfig.json
```
# Files
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | # Dependency directories
2 | node_modules/
3 | npm-debug.log
4 | yarn-debug.log
5 | yarn-error.log
6 |
7 | # Environment variables
8 | .env
9 | .env.local
10 | .env.development.local
11 | .env.test.local
12 | .env.production.local
13 |
14 | # Generated files
15 | generated-images/
16 | generated-audio/
17 |
18 | # Build output
19 | dist/
20 | build/
21 |
22 | # IDE and editor files
23 | .idea/
24 | .vscode/
25 | *.swp
26 | *.swo
27 | .DS_Store
28 |
29 | # Logs
30 | logs
31 | *.log
32 | npm-debug.log*
33 | yarn-debug.log*
34 | yarn-error.log*
35 |
36 | # Coverage directory used by tools like istanbul
37 | coverage/
38 |
39 | # nyc test coverage
40 | .nyc_output/
41 |
42 | # Optional npm cache directory
43 | .npm
44 |
45 | # Optional eslint cache
46 | .eslintcache
47 |
48 | .mcp.json
49 | mcp.json
50 |
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # Minimax MCP Tools
2 |
3 | 
4 |
5 | A Model Context Protocol (MCP) server for Minimax AI integration, providing async image generation and text-to-speech with advanced rate limiting and error handling.
6 |
7 | English | [简体中文](README.zh-CN.md)
8 |
9 | ### MCP Configuration
10 | Add to your MCP settings:
11 | ```json
12 | {
13 | "mcpServers": {
14 | "minimax-mcp-tools": {
15 | "command": "npx",
16 | "args": ["minimax-mcp-tools"],
17 | "env": {
18 | "MINIMAX_API_KEY": "your_api_key_here"
19 | }
20 | }
21 | }
22 | }
23 | ```
24 |
25 | ## Async Design - Perfect for Content Production at Scale
26 |
27 | This MCP server uses an **asynchronous submit-and-barrier pattern** designed for **batch content creation**:
28 |
29 | 🎬 **Narrated Slideshow Production** - Generate dozens of slide images and corresponding narration in parallel
30 | 📚 **AI-Driven Audiobook Creation** - Produce chapters with multiple voice characters simultaneously
31 | 🖼️ **Website Asset Generation** - Create consistent visual content and audio elements for web projects
32 | 🎯 **Multimedia Content Pipelines** - Perfect for LLM-driven content workflows requiring both visuals and audio
33 |
34 | ### Architecture Benefits:
35 | 1. **Submit Phase**: Tools return immediately with task IDs, tasks execute in background
36 | 2. **Smart Rate Limiting**: Adaptive rate limiting (10 RPM images, 20 RPM speech) with burst capacity
37 | 3. **Barrier Synchronization**: `task_barrier` waits for all tasks and returns comprehensive results
38 | 4. **Batch Optimization**: Submit multiple tasks to saturate rate limits, then barrier once for maximum throughput
39 |
40 |
41 |
42 | ## Tools
43 |
44 | ### `submit_image_generation`
45 | **Submit Image Generation Task** - Generate images asynchronously.
46 |
47 | **Required:** `prompt`, `outputFile`
48 | **Optional:** `aspectRatio`, `customSize`, `seed`, `subjectReference`, `style`
49 |
50 | ### `submit_speech_generation`
51 | **Submit Speech Generation Task** - Convert text to speech asynchronously.
52 |
53 | **Required:** `text`, `outputFile`
54 | **Optional:** `highQuality`, `voiceId`, `speed`, `volume`, `pitch`, `emotion`, `format`, `sampleRate`, `bitrate`, `languageBoost`, `intensity`, `timbre`, `sound_effects`
55 |
56 | ### `task_barrier`
57 | **Wait for Task Completion** - Wait for ALL submitted tasks to complete and retrieve results. Essential for batch processing.
58 |
59 | ## Architecture
60 | ```mermaid
61 | sequenceDiagram
62 | participant User
63 | participant MCP as MCP Server
64 | participant TM as Task Manager
65 | participant API as Minimax API
66 |
67 | Note over User, API: Async Submit-and-Barrier Pattern
68 |
69 | User->>MCP: submit_image_generation(prompt1)
70 | MCP->>TM: submitImageTask()
71 | TM-->>MCP: taskId: img-001
72 | MCP-->>User: "Task img-001 submitted"
73 |
74 | par Background Execution (Rate Limited)
75 | TM->>API: POST /image/generate
76 | API-->>TM: image data + save file
77 | end
78 |
79 | User->>MCP: submit_speech_generation(text1)
80 | MCP->>TM: submitTTSTask()
81 | TM-->>MCP: taskId: tts-002
82 | MCP-->>User: "Task tts-002 submitted"
83 |
84 | par Background Execution (Rate Limited)
85 | TM->>API: POST /speech/generate
86 | API-->>TM: audio data + save file
87 | end
88 |
89 | User->>MCP: submit_image_generation(prompt2)
90 | MCP->>TM: submitImageTask()
91 | TM-->>MCP: taskId: img-003
92 | MCP-->>User: "Task img-003 submitted"
93 |
94 | par Background Execution (Rate Limited)
95 | TM->>API: POST /image/generate (queued)
96 | API-->>TM: image data + save file
97 | end
98 |
99 | User->>MCP: task_barrier()
100 | MCP->>TM: barrier()
101 | TM->>TM: wait for all tasks
102 | TM-->>MCP: results summary
103 | MCP-->>User: ✅ All tasks completed<br/>Files available at specified paths
104 |
105 | Note over User, API: Immediate Task Submission + Background Rate-Limited Execution
106 | ```
107 |
108 | ## License
109 | MIT
```
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
```markdown
1 | # Changelog
2 |
3 | ## [2.2.0] - 2025-08-14
4 |
5 | ### Added
6 | - Speech 2.5 series models (`speech-2.5-hd-preview`, `speech-2.5-turbo-preview`)
7 | - 13 additional language boost options
8 |
9 | ### Changed
10 | - **BREAKING**: Removed Speech 2.0 series models
11 | - Default model: `speech-02-hd` → `speech-2.5-hd-preview`
12 |
13 | ### Fixed
14 | - Task barrier bug returning 0 completed tasks
15 |
```
--------------------------------------------------------------------------------
/.github/workflows/npm-publish.yml:
--------------------------------------------------------------------------------
```yaml
1 | # This workflow will build TypeScript and publish a package to npm when a release is created
2 | # For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages
3 |
4 | name: Build and Publish
5 |
6 | on:
7 | release:
8 | types: [created]
9 |
10 | jobs:
11 | publish-npm:
12 | runs-on: ubuntu-latest
13 | steps:
14 | - uses: actions/checkout@v4
15 |
16 | - uses: actions/setup-node@v4
17 | with:
18 | node-version: 20
19 | registry-url: https://registry.npmjs.org/
20 |
21 | - name: Install pnpm
22 | uses: pnpm/action-setup@v3
23 | with:
24 | version: 8
25 |
26 | - name: Install dependencies
27 | run: pnpm install --no-frozen-lockfile
28 |
29 | - name: Build TypeScript
30 | run: pnpm run build
31 |
32 | - name: Publish to npm
33 | run: pnpm publish --no-git-checks
34 | env:
35 | NODE_AUTH_TOKEN: ${{secrets.npm_token}}
36 |
```
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "compilerOptions": {
3 | "target": "ES2022",
4 | "module": "ESNext",
5 | "moduleResolution": "Node",
6 | "lib": ["ES2022", "DOM"],
7 | "outDir": "./dist",
8 | "rootDir": "./src",
9 | "strict": true,
10 | "esModuleInterop": true,
11 | "skipLibCheck": true,
12 | "forceConsistentCasingInFileNames": true,
13 | "declaration": true,
14 | "declarationMap": true,
15 | "sourceMap": true,
16 | "removeComments": true,
17 | "allowSyntheticDefaultImports": true,
18 | "resolveJsonModule": true,
19 | "experimentalDecorators": true,
20 | "emitDecoratorMetadata": true,
21 | "noImplicitAny": true,
22 | "noImplicitReturns": true,
23 | "noUnusedLocals": true,
24 | "noUnusedParameters": true,
25 | "exactOptionalPropertyTypes": true,
26 | "noImplicitOverride": true,
27 | "noPropertyAccessFromIndexSignature": true,
28 | "noUncheckedIndexedAccess": true
29 | },
30 | "include": [
31 | "src/**/*"
32 | ],
33 | "exclude": [
34 | "node_modules",
35 | "dist",
36 | "**/*.test.ts",
37 | "**/*.spec.ts"
38 | ],
39 | "ts-node": {
40 | "esm": true,
41 | "experimentalSpecifierResolution": "node"
42 | }
43 | }
```
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "minimax-mcp-tools",
3 | "version": "2.2.1",
4 | "description": "Async MCP server with Minimax API integration for image generation and text-to-speech",
5 | "type": "module",
6 | "main": "dist/index.js",
7 | "bin": {
8 | "minimax-mcp-tools": "dist/index.js"
9 | },
10 | "scripts": {
11 | "build": "tsc",
12 | "start": "node dist/index.js",
13 | "dev": "ts-node src/index.ts",
14 | "dev:watch": "nodemon --exec ts-node src/index.ts",
15 | "test": "echo \"Error: no test specified\" && exit 1",
16 | "prepublishOnly": "npm run build"
17 | },
18 | "keywords": [
19 | "mcp",
20 | "minimax",
21 | "ai",
22 | "image-generation",
23 | "text-to-speech",
24 | "tts"
25 | ],
26 | "author": "PsychArch (https://github.com/PsychArch)",
27 | "license": "MIT",
28 | "repository": {
29 | "type": "git",
30 | "url": "git+https://github.com/PsychArch/minimax-mcp-tools.git"
31 | },
32 | "bugs": {
33 | "url": "https://github.com/PsychArch/minimax-mcp-tools/issues"
34 | },
35 | "homepage": "https://github.com/PsychArch/minimax-mcp-tools#readme",
36 | "dependencies": {
37 | "@modelcontextprotocol/sdk": "^1.17.0",
38 | "node-fetch": "^3.3.2",
39 | "zod": "^3.25.76"
40 | },
41 | "devDependencies": {
42 | "@types/node": "^20.19.9",
43 | "nodemon": "^3.0.0",
44 | "ts-node": "^10.9.0",
45 | "typescript": "^5.3.0"
46 | },
47 | "engines": {
48 | "node": ">=16.0.0"
49 | },
50 | "files": [
51 | "dist/",
52 | "src/",
53 | "README.md",
54 | "README.zh-CN.md",
55 | "LICENSE",
56 | "assets/"
57 | ],
58 | "publishConfig": {
59 | "access": "public"
60 | }
61 | }
62 |
```
--------------------------------------------------------------------------------
/src/config/config-manager.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { MinimaxConfigError } from '../utils/error-handler.js';
2 |
3 | interface Config {
4 | apiKey: string;
5 | apiHost: string;
6 | logLevel: 'error' | 'debug';
7 | tempDir: string;
8 | maxConcurrency: number;
9 | retryAttempts: number;
10 | retryDelay: number;
11 | }
12 |
13 | interface RetryConfig {
14 | attempts: number;
15 | delay: number;
16 | }
17 |
18 | export class ConfigManager {
19 | private static instance: ConfigManager | null = null;
20 | private config!: Config;
21 |
22 | constructor() {
23 | if (ConfigManager.instance) {
24 | return ConfigManager.instance;
25 | }
26 |
27 | this.config = this.loadConfig();
28 | ConfigManager.instance = this;
29 | }
30 |
31 | static getInstance(): ConfigManager {
32 | if (!ConfigManager.instance) {
33 | ConfigManager.instance = new ConfigManager();
34 | }
35 | return ConfigManager.instance;
36 | }
37 |
38 | private loadConfig(): Config {
39 | return {
40 | apiKey: this.getRequiredEnv('MINIMAX_API_KEY'),
41 | apiHost: 'https://api.minimaxi.com',
42 | logLevel: 'error',
43 | tempDir: '/tmp',
44 | maxConcurrency: 5,
45 | retryAttempts: 3,
46 | retryDelay: 1000
47 | };
48 | }
49 |
50 | private getRequiredEnv(key: string): string {
51 | const value = process.env[key];
52 | if (!value) {
53 | throw new MinimaxConfigError(`Required environment variable ${key} is not set`);
54 | }
55 | return value;
56 | }
57 |
58 | get<K extends keyof Config>(key: K): Config[K] {
59 | return this.config[key];
60 | }
61 |
62 | getApiKey(): string {
63 | return this.config.apiKey;
64 | }
65 |
66 | getApiHost(): string | undefined {
67 | return this.config.apiHost;
68 | }
69 |
70 | getTempDir(): string {
71 | return this.config.tempDir;
72 | }
73 |
74 | getMaxConcurrency(): number {
75 | return this.config.maxConcurrency;
76 | }
77 |
78 | getRetryConfig(): RetryConfig {
79 | return {
80 | attempts: this.config.retryAttempts,
81 | delay: this.config.retryDelay
82 | };
83 | }
84 |
85 | isDebugMode(): boolean {
86 | return this.config.logLevel === 'debug';
87 | }
88 |
89 | // Validate configuration
90 | validate(): boolean {
91 | const required: Array<keyof Config> = ['apiKey'];
92 | const missing = required.filter(key => !this.config[key]);
93 |
94 | if (missing.length > 0) {
95 | throw new MinimaxConfigError(`Missing required configuration: ${missing.join(', ')}`);
96 | }
97 |
98 | return true;
99 | }
100 | }
```
--------------------------------------------------------------------------------
/src/core/rate-limiter.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { MinimaxRateLimitError } from '../utils/error-handler.js';
2 |
3 | interface RateLimiterConfig {
4 | rpm: number;
5 | burst?: number;
6 | window?: number;
7 | }
8 |
9 | interface AdaptiveRateLimiterConfig extends RateLimiterConfig {
10 | backoffFactor?: number;
11 | recoveryFactor?: number;
12 | maxBackoff?: number;
13 | }
14 |
15 | interface QueueRequest {
16 | resolve: () => void;
17 | reject: (error: Error) => void;
18 | timestamp: number;
19 | }
20 |
21 | interface RateLimiterStatus {
22 | tokens: number;
23 | queueLength: number;
24 | rpm: number;
25 | burst: number;
26 | }
27 |
28 | export interface AdaptiveStatus extends RateLimiterStatus {
29 | consecutiveErrors: number;
30 | adaptedRpm: number;
31 | originalRpm: number;
32 | }
33 |
34 | export class RateLimiter {
35 | protected rpm: number;
36 | protected burst: number;
37 | protected window: number;
38 | protected interval: number;
39 | protected tokens: number;
40 | protected lastRefill: number;
41 | protected queue: QueueRequest[];
42 |
43 | constructor({ rpm, burst = 1, window = 60000 }: RateLimiterConfig) {
44 | this.rpm = rpm;
45 | this.burst = burst;
46 | this.window = window;
47 | this.interval = window / rpm;
48 |
49 | // Token bucket algorithm
50 | this.tokens = burst;
51 | this.lastRefill = Date.now();
52 | this.queue = [];
53 | }
54 |
55 | async acquire(): Promise<void> {
56 | return new Promise<void>((resolve, reject) => {
57 | this.queue.push({ resolve, reject, timestamp: Date.now() });
58 | this.processQueue();
59 | });
60 | }
61 |
62 | private processQueue(): void {
63 | if (this.queue.length === 0) return;
64 |
65 | this.refillTokens();
66 |
67 | while (this.queue.length > 0 && this.tokens > 0) {
68 | const request = this.queue.shift();
69 | if (!request) break;
70 |
71 | this.tokens--;
72 |
73 | // Schedule the next refill
74 | const delay = Math.max(0, this.interval - (Date.now() - this.lastRefill));
75 | setTimeout(() => this.processQueue(), delay);
76 |
77 | request.resolve();
78 | }
79 | }
80 |
81 | private refillTokens(): void {
82 | const now = Date.now();
83 | const timePassed = now - this.lastRefill;
84 | const tokensToAdd = Math.floor(timePassed / this.interval);
85 |
86 | if (tokensToAdd > 0) {
87 | this.tokens = Math.min(this.burst, this.tokens + tokensToAdd);
88 | this.lastRefill = now;
89 | }
90 | }
91 |
92 | getStatus(): RateLimiterStatus {
93 | this.refillTokens();
94 | return {
95 | tokens: this.tokens,
96 | queueLength: this.queue.length,
97 | rpm: this.rpm,
98 | burst: this.burst
99 | };
100 | }
101 |
102 | reset(): void {
103 | this.tokens = this.burst;
104 | this.lastRefill = Date.now();
105 | this.queue = [];
106 | }
107 | }
108 |
109 | export class AdaptiveRateLimiter extends RateLimiter {
110 | private consecutiveErrors: number;
111 | private originalRpm: number;
112 | private backoffFactor: number;
113 | private recoveryFactor: number;
114 | private maxBackoff: number;
115 |
116 | constructor(config: AdaptiveRateLimiterConfig) {
117 | super(config);
118 | this.consecutiveErrors = 0;
119 | this.originalRpm = this.rpm;
120 | this.backoffFactor = config.backoffFactor || 0.5;
121 | this.recoveryFactor = config.recoveryFactor || 1.1;
122 | this.maxBackoff = config.maxBackoff || 5;
123 | }
124 |
125 | onSuccess(): void {
126 | // Gradually recover rate limit after success
127 | if (this.consecutiveErrors > 0) {
128 | this.consecutiveErrors = Math.max(0, this.consecutiveErrors - 1);
129 |
130 | if (this.consecutiveErrors === 0) {
131 | this.rpm = Math.min(this.originalRpm, this.rpm * this.recoveryFactor);
132 | this.interval = this.window / this.rpm;
133 | }
134 | }
135 | }
136 |
137 | onError(error: Error): void {
138 | if (error instanceof MinimaxRateLimitError) {
139 | this.consecutiveErrors++;
140 |
141 | // Reduce rate limit on consecutive errors
142 | const backoffMultiplier = Math.pow(this.backoffFactor, Math.min(this.consecutiveErrors, this.maxBackoff));
143 | this.rpm = Math.max(1, this.originalRpm * backoffMultiplier);
144 | this.interval = this.window / this.rpm;
145 |
146 | // Clear some tokens to enforce the new limit
147 | this.tokens = Math.min(this.tokens, Math.floor(this.burst * backoffMultiplier));
148 | }
149 | }
150 |
151 | getAdaptiveStatus(): AdaptiveStatus {
152 | return {
153 | ...this.getStatus(),
154 | consecutiveErrors: this.consecutiveErrors,
155 | adaptedRpm: this.rpm,
156 | originalRpm: this.originalRpm
157 | };
158 | }
159 | }
```
--------------------------------------------------------------------------------
/src/core/base-client.ts:
--------------------------------------------------------------------------------
```typescript
1 | import fetch from 'node-fetch';
2 | import type { RequestInit, Response } from 'node-fetch';
3 | import { ConfigManager } from '../config/config-manager.js';
4 | import { API_CONFIG } from '../config/constants.js';
5 | import { ErrorHandler, MinimaxError } from '../utils/error-handler.js';
6 |
7 | interface BaseClientOptions {
8 | baseURL?: string;
9 | timeout?: number;
10 | }
11 |
12 | interface RequestOptions extends Omit<RequestInit, 'body'> {
13 | body?: any;
14 | headers?: Record<string, string>;
15 | }
16 |
17 | interface HealthCheckResult {
18 | status: 'healthy' | 'unhealthy';
19 | timestamp: string;
20 | error?: string;
21 | }
22 |
23 | interface APIResponse {
24 | base_resp?: {
25 | status_code: number;
26 | status_msg?: string;
27 | };
28 | [key: string]: any;
29 | }
30 |
31 | export class MinimaxBaseClient {
32 | protected config: ConfigManager;
33 | protected baseURL: string;
34 | protected timeout: number;
35 | protected retryConfig: { attempts: number; delay: number };
36 |
37 | constructor(options: BaseClientOptions = {}) {
38 | this.config = ConfigManager.getInstance();
39 | this.baseURL = options.baseURL || API_CONFIG.BASE_URL;
40 | this.timeout = options.timeout || API_CONFIG.TIMEOUT;
41 | this.retryConfig = this.config.getRetryConfig();
42 | }
43 |
44 | async makeRequest(endpoint: string, options: RequestOptions = {}): Promise<APIResponse> {
45 | const url = `${this.baseURL}${endpoint}`;
46 | const headers: Record<string, string> = {
47 | 'Authorization': `Bearer ${this.config.getApiKey()}`,
48 | 'Content-Type': 'application/json',
49 | ...API_CONFIG.HEADERS,
50 | ...options.headers
51 | };
52 |
53 | const requestOptions: RequestInit & { timeout?: number } = {
54 | method: options.method || 'POST',
55 | headers,
56 | timeout: this.timeout,
57 | ...options
58 | };
59 |
60 | if (options.body && requestOptions.method !== 'GET') {
61 | requestOptions.body = JSON.stringify(options.body);
62 | }
63 |
64 | return this.executeWithRetry(url, requestOptions);
65 | }
66 |
67 | private async executeWithRetry(url: string, requestOptions: RequestInit & { timeout?: number }, attempt: number = 1): Promise<APIResponse> {
68 | try {
69 | const controller = new AbortController();
70 | const timeoutId = setTimeout(() => controller.abort(), this.timeout);
71 |
72 | const response: Response = await fetch(url, {
73 | ...requestOptions,
74 | signal: controller.signal
75 | });
76 |
77 | clearTimeout(timeoutId);
78 |
79 | if (!response.ok) {
80 | const errorText = await response.text();
81 | throw new Error(`HTTP ${response.status}: ${errorText}`);
82 | }
83 |
84 | const data = await response.json() as APIResponse;
85 | return this.processResponse(data);
86 |
87 | } catch (error: any) {
88 | const processedError = ErrorHandler.handleAPIError(error);
89 |
90 | // Retry logic for certain errors
91 | if (this.shouldRetry(processedError, attempt)) {
92 | await this.delay(this.retryConfig.delay * attempt);
93 | return this.executeWithRetry(url, requestOptions, attempt + 1);
94 | }
95 |
96 | throw processedError;
97 | }
98 | }
99 |
100 | private processResponse(data: APIResponse): APIResponse {
101 | // Check for API-level errors in response
102 | if (data.base_resp && data.base_resp.status_code !== 0) {
103 | throw ErrorHandler.handleAPIError(new Error('API Error'), data);
104 | }
105 |
106 | return data;
107 | }
108 |
109 | private shouldRetry(error: MinimaxError, attempt: number): boolean {
110 | if (attempt >= this.retryConfig.attempts) {
111 | return false;
112 | }
113 |
114 | // Retry on network errors, timeouts, and 5xx errors
115 | return (
116 | error.code === 'NETWORK_ERROR' ||
117 | error.code === 'TIMEOUT_ERROR' ||
118 | ('statusCode' in error && typeof error.statusCode === 'number' && error.statusCode >= 500 && error.statusCode < 600)
119 | );
120 | }
121 |
122 | private async delay(ms: number): Promise<void> {
123 | return new Promise(resolve => setTimeout(resolve, ms));
124 | }
125 |
126 | async get(endpoint: string, options: RequestOptions = {}): Promise<APIResponse> {
127 | return this.makeRequest(endpoint, { ...options, method: 'GET' });
128 | }
129 |
130 | async post(endpoint: string, body?: any, options: RequestOptions = {}): Promise<APIResponse> {
131 | return this.makeRequest(endpoint, { ...options, method: 'POST', body });
132 | }
133 |
134 | // Health check method
135 | async healthCheck(): Promise<HealthCheckResult> {
136 | try {
137 | // Make a simple request to verify connectivity
138 | await this.get('/health');
139 | return { status: 'healthy', timestamp: new Date().toISOString() };
140 | } catch (error: any) {
141 | return {
142 | status: 'unhealthy',
143 | error: ErrorHandler.formatErrorForUser(error),
144 | timestamp: new Date().toISOString()
145 | };
146 | }
147 | }
148 | }
```
--------------------------------------------------------------------------------
/src/utils/file-handler.ts:
--------------------------------------------------------------------------------
```typescript
1 | import fs from 'fs/promises';
2 | import path from 'path';
3 | import fetch from 'node-fetch';
4 | import type { RequestInit } from 'node-fetch';
5 | import { MinimaxError } from './error-handler.js';
6 |
7 | interface DownloadOptions {
8 | timeout?: number;
9 | fetchOptions?: RequestInit;
10 | }
11 |
12 | interface FileStats {
13 | size: number;
14 | isFile(): boolean;
15 | isDirectory(): boolean;
16 | mtime: Date;
17 | ctime: Date;
18 | }
19 |
20 | export class FileHandler {
21 | static async ensureDirectoryExists(filePath: string): Promise<void> {
22 | const dir = path.dirname(filePath);
23 | try {
24 | await fs.mkdir(dir, { recursive: true });
25 | } catch (error: any) {
26 | throw new MinimaxError(`Failed to create directory: ${error.message}`);
27 | }
28 | }
29 |
30 | static async writeFile(filePath: string, data: string | Buffer, options: any = {}): Promise<void> {
31 | try {
32 | await this.ensureDirectoryExists(filePath);
33 | await fs.writeFile(filePath, data, options);
34 | } catch (error: any) {
35 | throw new MinimaxError(`Failed to write file ${filePath}: ${error.message}`);
36 | }
37 | }
38 |
39 | static async readFile(filePath: string, options: any = {}): Promise<Buffer | string> {
40 | try {
41 | return await fs.readFile(filePath, options);
42 | } catch (error: any) {
43 | throw new MinimaxError(`Failed to read file ${filePath}: ${error.message}`);
44 | }
45 | }
46 |
47 | static async downloadFile(url: string, outputPath: string, options: DownloadOptions = {}): Promise<string> {
48 | try {
49 | await this.ensureDirectoryExists(outputPath);
50 |
51 | const response = await fetch(url, {
52 | ...options.fetchOptions
53 | });
54 |
55 | if (!response.ok) {
56 | throw new Error(`HTTP ${response.status}: ${response.statusText}`);
57 | }
58 |
59 | const buffer = await response.buffer();
60 | await fs.writeFile(outputPath, buffer);
61 |
62 | return outputPath;
63 | } catch (error: any) {
64 | throw new MinimaxError(`Failed to download file from ${url}: ${error.message}`);
65 | }
66 | }
67 |
68 | static async convertToBase64(input: string): Promise<string> {
69 | try {
70 | let buffer: Buffer;
71 |
72 | if (input.startsWith('http://') || input.startsWith('https://')) {
73 | // Download URL and convert to base64
74 | const response = await fetch(input);
75 | if (!response.ok) {
76 | throw new Error(`HTTP ${response.status}: ${response.statusText}`);
77 | }
78 | buffer = await response.buffer();
79 | } else {
80 | // Read local file
81 | const fileData = await this.readFile(input);
82 | buffer = Buffer.isBuffer(fileData) ? fileData : Buffer.from(fileData as string);
83 | }
84 |
85 | return `data:image/jpeg;base64,${buffer.toString('base64')}`;
86 | } catch (error: any) {
87 | throw new MinimaxError(`Failed to convert to base64: ${error.message}`);
88 | }
89 | }
90 |
91 | static generateUniqueFilename(basePath: string, index: number, total: number): string {
92 | if (total === 1) {
93 | return basePath;
94 | }
95 |
96 | const dir = path.dirname(basePath);
97 | const ext = path.extname(basePath);
98 | const name = path.basename(basePath, ext);
99 |
100 | return path.join(dir, `${name}_${String(index + 1).padStart(2, '0')}${ext}`);
101 | }
102 |
103 | static validateFilePath(filePath: string): boolean {
104 | if (!filePath || typeof filePath !== 'string') {
105 | throw new MinimaxError('File path must be a non-empty string');
106 | }
107 |
108 | if (!path.isAbsolute(filePath)) {
109 | throw new MinimaxError('File path must be absolute');
110 | }
111 |
112 | return true;
113 | }
114 |
115 | static getFileExtension(format: string): string {
116 | const extensions: Record<string, string> = {
117 | mp3: '.mp3',
118 | wav: '.wav',
119 | flac: '.flac',
120 | pcm: '.pcm',
121 | jpg: '.jpg',
122 | jpeg: '.jpeg',
123 | png: '.png',
124 | webp: '.webp'
125 | };
126 |
127 | return extensions[format.toLowerCase()] || `.${format}`;
128 | }
129 |
130 | static async fileExists(filePath: string): Promise<boolean> {
131 | try {
132 | await fs.access(filePath);
133 | return true;
134 | } catch {
135 | return false;
136 | }
137 | }
138 |
139 | static async getFileStats(filePath: string): Promise<FileStats> {
140 | try {
141 | const stats = await fs.stat(filePath);
142 | return {
143 | size: stats.size,
144 | isFile: () => stats.isFile(),
145 | isDirectory: () => stats.isDirectory(),
146 | mtime: stats.mtime,
147 | ctime: stats.ctime
148 | };
149 | } catch (error: any) {
150 | throw new MinimaxError(`Failed to get file stats: ${error.message}`);
151 | }
152 | }
153 |
154 | static async saveBase64Image(base64Data: string, outputPath: string): Promise<void> {
155 | try {
156 | await this.ensureDirectoryExists(outputPath);
157 |
158 | // Remove data URL prefix if present
159 | const cleanBase64 = base64Data.replace(/^data:image\/\w+;base64,/, '');
160 | const buffer = Buffer.from(cleanBase64, 'base64');
161 |
162 | await fs.writeFile(outputPath, buffer);
163 | } catch (error: any) {
164 | throw new MinimaxError(`Failed to save base64 image: ${error.message}`);
165 | }
166 | }
167 | }
```
--------------------------------------------------------------------------------
/src/services/image-service.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { MinimaxBaseClient } from '../core/base-client.js';
2 | import { API_CONFIG, DEFAULTS, MODELS, CONSTRAINTS, type ImageModel } from '../config/constants.js';
3 | import { FileHandler } from '../utils/file-handler.js';
4 | import { ErrorHandler } from '../utils/error-handler.js';
5 | import { type ImageGenerationParams } from '../config/schemas.js';
6 |
7 | interface ImageGenerationPayload {
8 | model: string;
9 | prompt: string;
10 | n: number;
11 | prompt_optimizer: boolean;
12 | response_format: string;
13 | width?: number;
14 | height?: number;
15 | aspect_ratio?: string;
16 | seed?: number;
17 | subject_reference?: Array<{
18 | type: string;
19 | image_file: string;
20 | }>;
21 | style?: {
22 | style_type: string;
23 | style_weight: number;
24 | };
25 | }
26 |
27 | interface ImageGenerationResponse {
28 | data?: {
29 | image_urls?: string[];
30 | image_base64?: string[];
31 | };
32 | }
33 |
34 | interface ImageGenerationResult {
35 | files: string[];
36 | count: number;
37 | model: string;
38 | prompt: string;
39 | warnings?: string[];
40 | }
41 |
42 | export class ImageGenerationService extends MinimaxBaseClient {
43 | constructor(options: { baseURL?: string; timeout?: number } = {}) {
44 | super(options);
45 | }
46 |
47 | async generateImage(params: ImageGenerationParams): Promise<ImageGenerationResult> {
48 | try {
49 | // Build API payload (MCP handles validation)
50 | const payload = this.buildPayload(params);
51 |
52 | // Make API request
53 | const response = await this.post(API_CONFIG.ENDPOINTS.IMAGE_GENERATION, payload) as ImageGenerationResponse;
54 |
55 | // Process response
56 | return await this.processImageResponse(response, params);
57 |
58 | } catch (error: any) {
59 | const processedError = ErrorHandler.handleAPIError(error);
60 | ErrorHandler.logError(processedError, { service: 'image', params });
61 |
62 | // Throw the error so task manager can properly mark it as failed
63 | throw processedError;
64 | }
65 | }
66 |
67 | private buildPayload(params: ImageGenerationParams): ImageGenerationPayload {
68 | const imageDefaults = DEFAULTS.IMAGE as any;
69 |
70 | // Choose model based on whether style is provided
71 | const model = params.style ? 'image-01-live' : 'image-01';
72 |
73 | const payload: ImageGenerationPayload = {
74 | model: model,
75 | prompt: params.prompt,
76 | n: 1,
77 | prompt_optimizer: true, // Always optimize prompts
78 | response_format: 'url' // Always use URL since we save to file
79 | };
80 |
81 | // Handle sizing parameters (conflict-free approach)
82 | if (params.customSize) {
83 | payload.width = params.customSize.width;
84 | payload.height = params.customSize.height;
85 | } else {
86 | payload.aspect_ratio = params.aspectRatio || imageDefaults.aspectRatio;
87 | }
88 |
89 | // Add optional parameters
90 | if (params.seed !== undefined) {
91 | payload.seed = params.seed;
92 | }
93 |
94 | // Model-specific parameter handling
95 | if (model === 'image-01') {
96 | // Add subject reference for image-01 model
97 | // MCP Server Bridge: Convert user-friendly file path to API format
98 | if (params.subjectReference) {
99 | // TODO: Convert file path/URL to base64 or ensure URL is accessible
100 | // For now, pass through assuming it's already in correct format
101 | payload.subject_reference = [{
102 | type: 'character',
103 | image_file: params.subjectReference
104 | }];
105 | }
106 | } else if (model === 'image-01-live') {
107 | // Add style settings for image-01-live model
108 | if (params.style) {
109 | payload.style = {
110 | style_type: params.style.style_type,
111 | style_weight: params.style.style_weight || 0.8
112 | };
113 | }
114 | }
115 |
116 |
117 | return payload;
118 | }
119 |
120 | private async processImageResponse(response: ImageGenerationResponse, params: ImageGenerationParams): Promise<ImageGenerationResult> {
121 | // Handle both URL and base64 responses
122 | const imageUrls = response.data?.image_urls || [];
123 | const imageBase64 = response.data?.image_base64 || [];
124 |
125 | if (!imageUrls.length && !imageBase64.length) {
126 | throw new Error('No images generated in API response');
127 | }
128 |
129 | // Download and save images
130 | const savedFiles: string[] = [];
131 | const errors: string[] = [];
132 | const imageSources = imageUrls.length ? imageUrls : imageBase64;
133 |
134 | for (let i = 0; i < imageSources.length; i++) {
135 | try {
136 | const filename = FileHandler.generateUniqueFilename(params.outputFile, i, imageSources.length);
137 |
138 | if (imageBase64.length && !imageUrls.length) {
139 | // Save base64 image
140 | await FileHandler.saveBase64Image(imageBase64[i]!, filename);
141 | } else {
142 | // Download from URL
143 | await FileHandler.downloadFile(imageSources[i]!, filename);
144 | }
145 |
146 | savedFiles.push(filename);
147 | } catch (error: any) {
148 | errors.push(`Image ${i + 1}: ${error.message}`);
149 | }
150 | }
151 |
152 | if (savedFiles.length === 0) {
153 | throw new Error(`Failed to save any images: ${errors.join('; ')}`);
154 | }
155 |
156 | // Use the actual model that was used
157 | const modelUsed = params.style ? 'image-01-live' : 'image-01';
158 |
159 | const result: ImageGenerationResult = {
160 | files: savedFiles,
161 | count: savedFiles.length,
162 | model: modelUsed,
163 | prompt: params.prompt
164 | };
165 |
166 | if (errors.length > 0) {
167 | result.warnings = errors;
168 | }
169 |
170 | return result;
171 | }
172 |
173 | // Utility methods
174 | async validateSubjectReference(reference: string): Promise<string | null> {
175 | if (!reference) return null;
176 |
177 | try {
178 | return await FileHandler.convertToBase64(reference);
179 | } catch (error: any) {
180 | throw new Error(`Invalid subject reference: ${error.message}`);
181 | }
182 | }
183 |
184 | getSupportedModels(): string[] {
185 | return Object.keys(MODELS.IMAGE);
186 | }
187 |
188 | getSupportedAspectRatios(): readonly string[] {
189 | return CONSTRAINTS.IMAGE.ASPECT_RATIOS;
190 | }
191 |
192 | getModelInfo(modelName: string): { name: string; description: string } | null {
193 | return MODELS.IMAGE[modelName as ImageModel] || null;
194 | }
195 |
196 | }
```
--------------------------------------------------------------------------------
/src/utils/error-handler.ts:
--------------------------------------------------------------------------------
```typescript
1 | // Custom error classes for better error handling
2 | export class MinimaxError extends Error {
3 | public readonly code: string;
4 | public readonly details: any;
5 | public readonly timestamp: string;
6 |
7 | constructor(message: string, code: string = 'MINIMAX_ERROR', details: any = null) {
8 | super(message);
9 | this.name = this.constructor.name;
10 | this.code = code;
11 | this.details = details;
12 | this.timestamp = new Date().toISOString();
13 | }
14 |
15 | toJSON(): {
16 | name: string;
17 | message: string;
18 | code: string;
19 | details: any;
20 | timestamp: string;
21 | } {
22 | return {
23 | name: this.name,
24 | message: this.message,
25 | code: this.code,
26 | details: this.details,
27 | timestamp: this.timestamp
28 | };
29 | }
30 | }
31 |
32 | export class MinimaxConfigError extends MinimaxError {
33 | constructor(message: string, details: any = null) {
34 | super(message, 'CONFIG_ERROR', details);
35 | }
36 | }
37 |
38 | export class MinimaxAPIError extends MinimaxError {
39 | public readonly statusCode: number | null;
40 | public readonly response: any;
41 |
42 | constructor(message: string, statusCode: number | null = null, response: any = null) {
43 | super(message, 'API_ERROR', { statusCode, response });
44 | this.statusCode = statusCode;
45 | this.response = response;
46 | }
47 | }
48 |
49 | export class MinimaxValidationError extends MinimaxError {
50 | public readonly field: string | null;
51 | public readonly value: any;
52 |
53 | constructor(message: string, field: string | null = null, value: any = null) {
54 | super(message, 'VALIDATION_ERROR', { field, value });
55 | this.field = field;
56 | this.value = value;
57 | }
58 | }
59 |
60 | export class MinimaxNetworkError extends MinimaxError {
61 | public readonly originalError: Error | null;
62 |
63 | constructor(message: string, originalError: Error | null = null) {
64 | super(message, 'NETWORK_ERROR', { originalError: originalError?.message });
65 | this.originalError = originalError;
66 | }
67 | }
68 |
69 | export class MinimaxTimeoutError extends MinimaxError {
70 | public readonly timeout: number | null;
71 |
72 | constructor(message: string, timeout: number | null = null) {
73 | super(message, 'TIMEOUT_ERROR', { timeout });
74 | this.timeout = timeout;
75 | }
76 | }
77 |
78 | export class MinimaxRateLimitError extends MinimaxError {
79 | public readonly retryAfter: number | null;
80 |
81 | constructor(message: string, retryAfter: number | null = null) {
82 | super(message, 'RATE_LIMIT_ERROR', { retryAfter });
83 | this.retryAfter = retryAfter;
84 | }
85 | }
86 |
87 | // API Response interface for better typing
88 | interface APIResponse {
89 | base_resp?: {
90 | status_code: number;
91 | status_msg?: string;
92 | retry_after?: number;
93 | };
94 | }
95 |
96 | // Error with common Node.js error properties
97 | interface NodeError extends Error {
98 | code?: string;
99 | timeout?: number;
100 | }
101 |
102 | // Error handler utility functions
103 | export class ErrorHandler {
104 | static handleAPIError(error: NodeError, response?: APIResponse): MinimaxError {
105 | // Handle different types of API errors
106 | if (response?.base_resp && response.base_resp.status_code !== 0) {
107 | const statusCode = response.base_resp.status_code;
108 | const message = response.base_resp.status_msg || 'API request failed';
109 |
110 | switch (statusCode) {
111 | case 1004:
112 | return new MinimaxAPIError(`Authentication failed: ${message}`, statusCode, response);
113 | case 1013:
114 | return new MinimaxRateLimitError(`Rate limit exceeded: ${message}`, response.base_resp?.retry_after);
115 | default:
116 | return new MinimaxAPIError(message, statusCode, response);
117 | }
118 | }
119 |
120 | // Handle HTTP errors
121 | if (error.message && error.message.includes('HTTP')) {
122 | const match = error.message.match(/HTTP (\d+):/);
123 | const statusCode = match ? parseInt(match[1]!, 10) : null;
124 |
125 | switch (statusCode) {
126 | case 401:
127 | return new MinimaxAPIError('Unauthorized: Invalid API key', statusCode!);
128 | case 403:
129 | return new MinimaxAPIError('Forbidden: Access denied', statusCode!);
130 | case 404:
131 | return new MinimaxAPIError('Not found: Invalid endpoint', statusCode!);
132 | case 429:
133 | return new MinimaxRateLimitError('Rate limit exceeded', null);
134 | case 500:
135 | return new MinimaxAPIError('Internal server error', statusCode!);
136 | default:
137 | return new MinimaxAPIError(error.message, statusCode!);
138 | }
139 | }
140 |
141 | // Handle network errors
142 | if (error.code === 'ECONNREFUSED' || error.code === 'ENOTFOUND') {
143 | return new MinimaxNetworkError('Network connection failed', error);
144 | }
145 |
146 | // Handle timeout errors
147 | if (error.name === 'AbortError' || (error.message && error.message.includes('timeout'))) {
148 | return new MinimaxTimeoutError('Request timeout', error.timeout);
149 | }
150 |
151 | // Default to generic error
152 | return new MinimaxError(error.message || 'Unknown error occurred');
153 | }
154 |
155 | static formatErrorForUser(error: Error): string {
156 | if (error instanceof MinimaxConfigError) {
157 | return `Configuration Error: ${error.message}`;
158 | }
159 |
160 | if (error instanceof MinimaxValidationError) {
161 | return `Validation Error: ${error.message}`;
162 | }
163 |
164 | if (error instanceof MinimaxAPIError) {
165 | return `API Error: ${error.message}`;
166 | }
167 |
168 | if (error instanceof MinimaxNetworkError) {
169 | return `Network Error: ${error.message}`;
170 | }
171 |
172 | if (error instanceof MinimaxTimeoutError) {
173 | return `Timeout Error: ${error.message}`;
174 | }
175 |
176 | if (error instanceof MinimaxRateLimitError) {
177 | return `Rate Limit Error: ${error.message}`;
178 | }
179 |
180 | return `Error: ${error.message}`;
181 | }
182 |
183 | static logError(error: Error, context: Record<string, any> = {}): void {
184 | const logEntry = {
185 | timestamp: new Date().toISOString(),
186 | error: error instanceof MinimaxError ? error.toJSON() : {
187 | name: error.name,
188 | message: error.message,
189 | stack: error.stack
190 | },
191 | context
192 | };
193 |
194 | if (typeof console !== 'undefined') {
195 | console.error('[MINIMAX-ERROR]', JSON.stringify(logEntry, null, 2));
196 | }
197 | }
198 | }
```
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | #!/usr/bin/env node
2 |
3 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5 |
6 | // Import refactored components
7 | import { ConfigManager } from './config/config-manager.js';
8 | import {
9 | imageGenerationSchema,
10 | textToSpeechSchema,
11 | taskBarrierSchema,
12 | validateImageParams,
13 | validateTTSParams,
14 | validateTaskBarrierParams
15 | } from './config/schemas.js';
16 | import { ImageGenerationService } from './services/image-service.js';
17 | import { TextToSpeechService } from './services/tts-service.js';
18 | import { RateLimitedTaskManager } from './core/task-manager.js';
19 | import { ErrorHandler } from './utils/error-handler.js';
20 |
21 | // MCP Tool Response interface
22 | interface ToolResponse {
23 | [x: string]: unknown;
24 | content: Array<{
25 | type: "text";
26 | text: string;
27 | }>;
28 | }
29 |
30 | // Initialize configuration and services
31 | let config: ConfigManager;
32 | let imageService: ImageGenerationService;
33 | let ttsService: TextToSpeechService;
34 | let taskManager: RateLimitedTaskManager;
35 |
36 | try {
37 | config = ConfigManager.getInstance();
38 | config.validate();
39 |
40 | imageService = new ImageGenerationService();
41 | ttsService = new TextToSpeechService();
42 | taskManager = new RateLimitedTaskManager();
43 |
44 | } catch (error: any) {
45 | console.error("❌ Failed to initialize:", ErrorHandler.formatErrorForUser(error));
46 | process.exit(1);
47 | }
48 |
49 | // Create MCP server
50 | const server = new McpServer({
51 | name: "minimax-mcp-tools",
52 | version: "2.2.0",
53 | description: "Async Minimax AI integration for image generation and text-to-speech"
54 | });
55 |
56 | // Image generation tool
57 | server.registerTool(
58 | "submit_image_generation",
59 | {
60 | title: "Submit Image Generation Task",
61 | description: "Generate images asynchronously. RECOMMENDED: Submit multiple tasks in batch to saturate rate limits, then call task_barrier once to wait for all completions. Returns task ID only - actual files available after task_barrier.",
62 | inputSchema: imageGenerationSchema.shape
63 | },
64 | async (params: unknown): Promise<ToolResponse> => {
65 | try {
66 | const validatedParams = validateImageParams(params);
67 | const { taskId } = await taskManager.submitImageTask(async () => {
68 | return await imageService.generateImage(validatedParams);
69 | });
70 |
71 | return {
72 | content: [{
73 | type: "text",
74 | text: `Task ${taskId} submitted`
75 | }]
76 | };
77 | } catch (error: any) {
78 | ErrorHandler.logError(error, { tool: 'submit_image_generation', params });
79 | return {
80 | content: [{
81 | type: "text",
82 | text: `❌ Failed to submit image generation task: ${ErrorHandler.formatErrorForUser(error)}`
83 | }]
84 | };
85 | }
86 | }
87 | );
88 |
89 | // Text-to-speech tool
90 | server.registerTool(
91 | "submit_speech_generation",
92 | {
93 | title: "Submit Speech Generation Task",
94 | description: "Convert text to speech asynchronously. RECOMMENDED: Submit multiple tasks in batch to saturate rate limits, then call task_barrier once to wait for all completions. Returns task ID only - actual files available after task_barrier.",
95 | inputSchema: textToSpeechSchema.shape
96 | },
97 | async (params: unknown): Promise<ToolResponse> => {
98 | try {
99 | const validatedParams = validateTTSParams(params);
100 | const { taskId } = await taskManager.submitTTSTask(async () => {
101 | return await ttsService.generateSpeech(validatedParams);
102 | });
103 |
104 | return {
105 | content: [{
106 | type: "text",
107 | text: `Task ${taskId} submitted`
108 | }]
109 | };
110 | } catch (error: any) {
111 | ErrorHandler.logError(error, { tool: 'submit_speech_generation', params });
112 | return {
113 | content: [{
114 | type: "text",
115 | text: `❌ Failed to submit TTS task: ${ErrorHandler.formatErrorForUser(error)}`
116 | }]
117 | };
118 | }
119 | }
120 | );
121 |
122 | // Task barrier tool
123 | server.registerTool(
124 | "task_barrier",
125 | {
126 | title: "Wait for Task Completion",
127 | description: "Wait for ALL submitted tasks to complete and retrieve results. Essential for batch processing - submit multiple tasks first, then call task_barrier once to collect all results efficiently. Clears completed tasks.",
128 | inputSchema: taskBarrierSchema.shape
129 | },
130 | async (params: unknown): Promise<ToolResponse> => {
131 | try {
132 | validateTaskBarrierParams(params);
133 | const { completed, results } = await taskManager.barrier();
134 |
135 | if (completed === 0) {
136 | return {
137 | content: [{
138 | type: "text",
139 | text: "ℹ️ No tasks were submitted before this barrier."
140 | }]
141 | };
142 | }
143 |
144 | // Format results
145 | const resultSummaries = results.map(({ taskId, success, result, error }) => {
146 | if (!success) {
147 | return `❌ Task ${taskId}: FAILED - ${error?.message || 'Unknown error'}`;
148 | }
149 |
150 | // Format success results based on task type
151 | if (result?.files) {
152 | // Image generation result
153 | const warnings = result.warnings ? ` (${result.warnings.length} warnings)` : '';
154 | return `✅ Task ${taskId}: Generated ${result.count} image(s)${warnings}`;
155 | } else if (result?.audioFile) {
156 | // TTS generation result
157 | const subtitles = result.subtitleFile ? ` + subtitles` : '';
158 | const warnings = result.warnings ? ` (${result.warnings.length} warnings)` : '';
159 | return `✅ Task ${taskId}: Generated speech${subtitles}${warnings}`;
160 | } else {
161 | // Generic success
162 | return `✅ Task ${taskId}: Completed successfully`;
163 | }
164 | });
165 |
166 | const summary = resultSummaries.join('\n');
167 |
168 | // Clear completed tasks to prevent memory leaks
169 | taskManager.clearCompletedTasks();
170 |
171 | return {
172 | content: [{
173 | type: "text",
174 | text: summary
175 | }]
176 | };
177 | } catch (error: any) {
178 | ErrorHandler.logError(error, { tool: 'task_barrier' });
179 | return {
180 | content: [{
181 | type: "text",
182 | text: `❌ Task barrier failed: ${ErrorHandler.formatErrorForUser(error)}`
183 | }]
184 | };
185 | }
186 | }
187 | );
188 |
189 |
190 | // Graceful shutdown
191 | process.on('SIGINT', () => {
192 | console.error("🛑 Shutting down gracefully...");
193 | taskManager.clearCompletedTasks();
194 | process.exit(0);
195 | });
196 |
197 | process.on('SIGTERM', () => {
198 | console.error("🛑 Received SIGTERM, shutting down...");
199 | taskManager.clearCompletedTasks();
200 | process.exit(0);
201 | });
202 |
203 | // Start server
204 | const transport = new StdioServerTransport();
205 | await server.connect(transport);
206 |
```
--------------------------------------------------------------------------------
/src/core/task-manager.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { AdaptiveRateLimiter } from './rate-limiter.js';
2 | import { RATE_LIMITS } from '../config/constants.js';
3 | import { MinimaxError, ErrorHandler } from '../utils/error-handler.js';
4 |
5 | // Type definitions
6 | interface TaskResult {
7 | success: boolean;
8 | result?: any;
9 | error?: MinimaxError;
10 | completedAt: number;
11 | }
12 |
13 | interface TaskSubmissionResult {
14 | taskId: string;
15 | promise: Promise<any>;
16 | }
17 |
18 | interface BarrierResult {
19 | completed: number;
20 | results: (TaskResult & { taskId: string })[];
21 | }
22 |
23 | interface TaskStatus {
24 | status: 'running' | 'completed' | 'not_found';
25 | taskId: string;
26 | success?: boolean;
27 | result?: any;
28 | error?: MinimaxError;
29 | completedAt?: number;
30 | }
31 |
32 | interface AllTasksStatus {
33 | running: Array<{ taskId: string; status: 'running' }>;
34 | completed: Array<{ taskId: string; status: 'completed' } & TaskResult>;
35 | total: number;
36 | }
37 |
38 | interface TaskStats {
39 | activeTasks: number;
40 | completedTasks: number;
41 | totalProcessed: number;
42 | }
43 |
44 | interface TaskMetrics {
45 | requests: number;
46 | successes: number;
47 | errors: number;
48 | }
49 |
50 | interface RateLimitedTaskManagerOptions {
51 | backoffFactor?: number;
52 | recoveryFactor?: number;
53 | }
54 |
55 | export class TaskManager {
56 | protected tasks: Map<string, Promise<any>>;
57 | protected completedTasks: Map<string, TaskResult>;
58 | protected taskCounter: number;
59 |
60 | constructor() {
61 | this.tasks = new Map();
62 | this.completedTasks = new Map();
63 | this.taskCounter = 0;
64 | }
65 |
66 | protected generateTaskId(): string {
67 | return `task_${++this.taskCounter}`;
68 | }
69 |
70 | async submit(fn: () => Promise<any>, taskId: string | null = null): Promise<TaskSubmissionResult> {
71 | taskId = taskId || this.generateTaskId();
72 |
73 | const taskPromise = Promise.resolve()
74 | .then(fn)
75 | .then(result => {
76 | this.completedTasks.set(taskId!, { success: true, result, completedAt: Date.now() });
77 | return result;
78 | })
79 | .catch(error => {
80 | const processedError = ErrorHandler.handleAPIError(error);
81 | this.completedTasks.set(taskId!, { success: false, error: processedError, completedAt: Date.now() });
82 | throw processedError;
83 | })
84 | .finally(() => {
85 | this.tasks.delete(taskId!);
86 | });
87 |
88 | this.tasks.set(taskId, taskPromise);
89 | return { taskId, promise: taskPromise };
90 | }
91 |
92 | async barrier(): Promise<BarrierResult> {
93 | const activeTasks = Array.from(this.tasks.values());
94 |
95 | // Wait for any active tasks to complete
96 | if (activeTasks.length > 0) {
97 | await Promise.allSettled(activeTasks);
98 | }
99 |
100 | // Return all completed tasks (including those completed before this barrier call)
101 | const results = Array.from(this.completedTasks.entries()).map(([taskId, taskResult]) => ({
102 | taskId,
103 | ...taskResult
104 | }));
105 |
106 | return { completed: results.length, results };
107 | }
108 |
109 | getTaskStatus(taskId: string): TaskStatus {
110 | if (this.tasks.has(taskId)) {
111 | return { status: 'running', taskId };
112 | }
113 |
114 | if (this.completedTasks.has(taskId)) {
115 | return { status: 'completed', taskId, ...this.completedTasks.get(taskId)! };
116 | }
117 |
118 | return { status: 'not_found', taskId };
119 | }
120 |
121 | getAllTasksStatus(): AllTasksStatus {
122 | const running = Array.from(this.tasks.keys()).map(taskId => ({ taskId, status: 'running' as const }));
123 | const completed = Array.from(this.completedTasks.entries()).map(([taskId, result]) => ({
124 | taskId,
125 | status: 'completed' as const,
126 | ...result
127 | }));
128 |
129 | return { running, completed, total: running.length + completed.length };
130 | }
131 |
132 | clearCompletedTasks(): number {
133 | const count = this.completedTasks.size;
134 | this.completedTasks.clear();
135 | return count;
136 | }
137 |
138 | getStats(): TaskStats {
139 | return {
140 | activeTasks: this.tasks.size,
141 | completedTasks: this.completedTasks.size,
142 | totalProcessed: this.taskCounter
143 | };
144 | }
145 | }
146 |
147 | export class RateLimitedTaskManager extends TaskManager {
148 | private rateLimiters: {
149 | image: AdaptiveRateLimiter;
150 | tts: AdaptiveRateLimiter;
151 | };
152 | private metrics: {
153 | image: TaskMetrics;
154 | tts: TaskMetrics;
155 | };
156 | private taskCounters: {
157 | image: number;
158 | tts: number;
159 | };
160 |
161 | constructor(options: RateLimitedTaskManagerOptions = {}) {
162 | super();
163 |
164 | this.rateLimiters = {
165 | image: new AdaptiveRateLimiter({
166 | ...RATE_LIMITS.IMAGE,
167 | backoffFactor: options.backoffFactor || 0.7,
168 | recoveryFactor: options.recoveryFactor || 1.05
169 | }),
170 | tts: new AdaptiveRateLimiter({
171 | ...RATE_LIMITS.TTS,
172 | backoffFactor: options.backoffFactor || 0.7,
173 | recoveryFactor: options.recoveryFactor || 1.05
174 | })
175 | };
176 |
177 | this.metrics = {
178 | image: { requests: 0, successes: 0, errors: 0 },
179 | tts: { requests: 0, successes: 0, errors: 0 }
180 | };
181 |
182 | this.taskCounters = {
183 | image: 0,
184 | tts: 0
185 | };
186 | }
187 |
188 | async submitImageTask(fn: () => Promise<any>, taskId: string | null = null): Promise<TaskSubmissionResult> {
189 | if (!taskId) {
190 | taskId = `img-${++this.taskCounters.image}`;
191 | }
192 | return this.submitRateLimitedTask('image', fn, taskId);
193 | }
194 |
195 | async submitTTSTask(fn: () => Promise<any>, taskId: string | null = null): Promise<TaskSubmissionResult> {
196 | if (!taskId) {
197 | taskId = `tts-${++this.taskCounters.tts}`;
198 | }
199 | return this.submitRateLimitedTask('tts', fn, taskId);
200 | }
201 |
202 | private async submitRateLimitedTask(type: 'image' | 'tts', fn: () => Promise<any>, taskId: string | null = null): Promise<TaskSubmissionResult> {
203 | const rateLimiter = this.rateLimiters[type];
204 | if (!rateLimiter) {
205 | throw new MinimaxError(`Unknown task type: ${type}`);
206 | }
207 |
208 | const wrappedFn = async () => {
209 | await rateLimiter.acquire();
210 | this.metrics[type].requests++;
211 |
212 | try {
213 | const result = await fn();
214 | this.metrics[type].successes++;
215 | rateLimiter.onSuccess();
216 | return result;
217 | } catch (error: any) {
218 | this.metrics[type].errors++;
219 | rateLimiter.onError(error);
220 | throw error;
221 | }
222 | };
223 |
224 | return this.submit(wrappedFn, taskId);
225 | }
226 |
227 | getRateLimiterStatus() {
228 | return {
229 | image: this.rateLimiters.image.getAdaptiveStatus(),
230 | tts: this.rateLimiters.tts.getAdaptiveStatus()
231 | };
232 | }
233 |
234 | getMetrics() {
235 | return {
236 | ...this.metrics,
237 | rateLimiters: this.getRateLimiterStatus()
238 | };
239 | }
240 |
241 | resetMetrics(): void {
242 | this.metrics = {
243 | image: { requests: 0, successes: 0, errors: 0 },
244 | tts: { requests: 0, successes: 0, errors: 0 }
245 | };
246 |
247 | this.taskCounters = {
248 | image: 0,
249 | tts: 0
250 | };
251 |
252 | Object.values(this.rateLimiters).forEach(limiter => limiter.reset());
253 | }
254 | }
```
--------------------------------------------------------------------------------
/src/services/tts-service.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { MinimaxBaseClient } from '../core/base-client.js';
2 | import { API_CONFIG, DEFAULTS, MODELS, VOICES, type TTSModel, type VoiceId } from '../config/constants.js';
3 | import { FileHandler } from '../utils/file-handler.js';
4 | import { ErrorHandler } from '../utils/error-handler.js';
5 | import { type TextToSpeechParams } from '../config/schemas.js';
6 |
7 | interface TTSPayload {
8 | model: string;
9 | text: string;
10 | voice_setting: {
11 | voice_id: string;
12 | speed: number;
13 | vol: number;
14 | pitch: number;
15 | emotion: string;
16 | };
17 | audio_setting: {
18 | sample_rate: number;
19 | bitrate: number;
20 | format: string;
21 | channel: number;
22 | };
23 | language_boost?: string;
24 | voice_modify?: {
25 | pitch?: number;
26 | intensity?: number;
27 | timbre?: number;
28 | sound_effects?: string;
29 | };
30 | }
31 |
32 | interface TTSResponse {
33 | data?: {
34 | audio?: string;
35 | duration?: number;
36 | subtitle_url?: string;
37 | };
38 | }
39 |
40 | interface TTSResult {
41 | audioFile: string;
42 | voiceUsed: string;
43 | model: string;
44 | duration: number | null;
45 | format: string;
46 | sampleRate: number;
47 | bitrate: number;
48 | subtitleFile?: string;
49 | warnings?: string[];
50 | }
51 |
52 |
53 | export class TextToSpeechService extends MinimaxBaseClient {
54 | constructor(options: { baseURL?: string; timeout?: number } = {}) {
55 | super(options);
56 | }
57 |
58 | async generateSpeech(params: TextToSpeechParams): Promise<TTSResult> {
59 | try {
60 | // Build API payload (MCP handles validation)
61 | const payload = this.buildPayload(params);
62 |
63 | // Make API request
64 | const response = await this.post(API_CONFIG.ENDPOINTS.TEXT_TO_SPEECH, payload) as TTSResponse;
65 |
66 | // Process response
67 | return await this.processTTSResponse(response, params);
68 |
69 | } catch (error: any) {
70 | const processedError = ErrorHandler.handleAPIError(error);
71 | ErrorHandler.logError(processedError, { service: 'tts', params });
72 |
73 | // Throw the error so task manager can properly mark it as failed
74 | throw processedError;
75 | }
76 | }
77 |
78 | private buildPayload(params: TextToSpeechParams): TTSPayload {
79 | const ttsDefaults = DEFAULTS.TTS as any;
80 | // Map highQuality parameter to appropriate Speech 2.6 model
81 | const model = (params as any).highQuality ? 'speech-2.6-hd' : 'speech-2.6-turbo';
82 | const payload: TTSPayload = {
83 | model: model,
84 | text: params.text,
85 | voice_setting: {
86 | voice_id: params.voiceId || ttsDefaults.voiceId,
87 | speed: params.speed || ttsDefaults.speed,
88 | vol: params.volume || ttsDefaults.volume,
89 | pitch: params.pitch || ttsDefaults.pitch,
90 | emotion: params.emotion || ttsDefaults.emotion
91 | },
92 | audio_setting: {
93 | sample_rate: parseInt(params.sampleRate || ttsDefaults.sampleRate),
94 | bitrate: parseInt(params.bitrate || ttsDefaults.bitrate),
95 | format: params.format || ttsDefaults.format,
96 | channel: ttsDefaults.channel
97 | }
98 | };
99 |
100 | // Add optional parameters
101 | if (params.languageBoost) {
102 | payload.language_boost = params.languageBoost;
103 | }
104 |
105 | // Add voice modify parameters if present
106 | if (params.intensity !== undefined || params.timbre !== undefined || params.sound_effects !== undefined) {
107 | payload.voice_modify = {};
108 |
109 | if (params.intensity !== undefined) {
110 | payload.voice_modify.intensity = params.intensity;
111 | }
112 |
113 | if (params.timbre !== undefined) {
114 | payload.voice_modify.timbre = params.timbre;
115 | }
116 |
117 | if (params.sound_effects !== undefined) {
118 | payload.voice_modify.sound_effects = params.sound_effects;
119 | }
120 | }
121 |
122 | // Voice mixing feature removed for simplicity
123 |
124 | // Filter out undefined values
125 | return this.cleanPayload(payload) as TTSPayload;
126 | }
127 |
128 | private cleanPayload(obj: any): any {
129 | if (typeof obj !== 'object' || obj === null) {
130 | return obj;
131 | }
132 |
133 | if (Array.isArray(obj)) {
134 | return obj.map(item => this.cleanPayload(item)).filter(item => item !== undefined);
135 | }
136 |
137 | const result: any = {};
138 | for (const [key, value] of Object.entries(obj)) {
139 | if (value === undefined) continue;
140 |
141 | if (typeof value === 'object' && value !== null) {
142 | const cleanedValue = this.cleanPayload(value);
143 | if (typeof cleanedValue === 'object' && !Array.isArray(cleanedValue) && Object.keys(cleanedValue).length === 0) {
144 | continue;
145 | }
146 | result[key] = cleanedValue;
147 | } else {
148 | result[key] = value;
149 | }
150 | }
151 |
152 | return result;
153 | }
154 |
155 | private async processTTSResponse(response: TTSResponse, params: TextToSpeechParams): Promise<TTSResult> {
156 | const audioHex = response.data?.audio;
157 |
158 | if (!audioHex) {
159 | throw new Error('No audio data received from API');
160 | }
161 |
162 | // Convert hex to bytes and save
163 | const audioBytes = Buffer.from(audioHex, 'hex');
164 | await FileHandler.writeFile(params.outputFile, audioBytes);
165 |
166 | const ttsDefaults = DEFAULTS.TTS as any;
167 | const result: TTSResult = {
168 | audioFile: params.outputFile,
169 | voiceUsed: params.voiceId || ttsDefaults.voiceId,
170 | model: (params as any).highQuality ? 'speech-2.6-hd' : 'speech-2.6-turbo',
171 | duration: response.data?.duration || null,
172 | format: params.format || ttsDefaults.format,
173 | sampleRate: parseInt(params.sampleRate || ttsDefaults.sampleRate),
174 | bitrate: parseInt(params.bitrate || ttsDefaults.bitrate)
175 | };
176 |
177 | // Subtitles feature removed for simplicity
178 |
179 | return result;
180 | }
181 |
182 | // Utility methods
183 | getSupportedModels(): string[] {
184 | return Object.keys(MODELS.TTS);
185 | }
186 |
187 | getSupportedVoices(): string[] {
188 | return Object.keys(VOICES);
189 | }
190 |
191 | getVoiceInfo(voiceId: string): { name: string; gender: 'male' | 'female' | 'other'; language: 'zh' | 'en' } | null {
192 | return VOICES[voiceId as VoiceId] || null;
193 | }
194 |
195 | getModelInfo(modelName: string): { name: string; description: string } | null {
196 | return MODELS.TTS[modelName as TTSModel] || null;
197 | }
198 |
199 |
200 | validateVoiceParameters(params: TextToSpeechParams): string[] {
201 | const ttsDefaults = DEFAULTS.TTS as any;
202 | const voice = this.getVoiceInfo(params.voiceId || ttsDefaults.voiceId);
203 | const model = (params as any).highQuality ? 'speech-2.6-hd' : 'speech-2.6-turbo';
204 |
205 | const issues: string[] = [];
206 |
207 | if (!voice && params.voiceId) {
208 | issues.push(`Unknown voice ID: ${params.voiceId}`);
209 | }
210 |
211 | // Check emotion compatibility (Speech 2.6 models support emotions)
212 | if (params.emotion && params.emotion !== 'neutral') {
213 | const emotionSupportedModels = ['speech-2.6-hd', 'speech-2.6-turbo'];
214 | if (!emotionSupportedModels.includes(model)) {
215 | issues.push(`Emotion parameter not supported by model ${model}`);
216 | }
217 | }
218 |
219 | return issues;
220 | }
221 |
222 | }
```
--------------------------------------------------------------------------------
/src/config/constants.ts:
--------------------------------------------------------------------------------
```typescript
1 | // Type definitions
2 | export interface ModelConfig {
3 | name: string;
4 | description: string;
5 | }
6 |
7 | export interface VoiceConfig {
8 | name: string;
9 | gender: 'male' | 'female' | 'other';
10 | language: 'zh' | 'en';
11 | }
12 |
13 | export interface RateLimit {
14 | rpm: number;
15 | burst: number;
16 | }
17 |
18 | export interface ApiConfig {
19 | BASE_URL: string;
20 | ENDPOINTS: {
21 | IMAGE_GENERATION: string;
22 | TEXT_TO_SPEECH: string;
23 | };
24 | HEADERS: {
25 | 'MM-API-Source': string;
26 | };
27 | TIMEOUT: number;
28 | }
29 |
30 | export interface ImageConstraints {
31 | PROMPT_MAX_LENGTH: number;
32 | MAX_IMAGES: number;
33 | MIN_DIMENSION: number;
34 | MAX_DIMENSION: number;
35 | DIMENSION_STEP: number;
36 | ASPECT_RATIOS: readonly string[];
37 | STYLE_TYPES: readonly string[];
38 | RESPONSE_FORMATS: readonly string[];
39 | SUBJECT_TYPES: readonly string[];
40 | STYLE_WEIGHT_MIN: number;
41 | STYLE_WEIGHT_MAX: number;
42 | }
43 |
44 | export interface TTSConstraints {
45 | TEXT_MAX_LENGTH: number;
46 | SPEED_MIN: number;
47 | SPEED_MAX: number;
48 | VOLUME_MIN: number;
49 | VOLUME_MAX: number;
50 | PITCH_MIN: number;
51 | PITCH_MAX: number;
52 | EMOTIONS: readonly string[];
53 | FORMATS: readonly string[];
54 | SAMPLE_RATES: readonly number[];
55 | BITRATES: readonly number[];
56 | VOICE_MODIFY_PITCH_MIN: number;
57 | VOICE_MODIFY_PITCH_MAX: number;
58 | VOICE_MODIFY_INTENSITY_MIN: number;
59 | VOICE_MODIFY_INTENSITY_MAX: number;
60 | VOICE_MODIFY_TIMBRE_MIN: number;
61 | VOICE_MODIFY_TIMBRE_MAX: number;
62 | SOUND_EFFECTS: readonly string[];
63 | }
64 |
65 | export interface ImageDefaults {
66 | model: string;
67 | aspectRatio: string;
68 | n: number;
69 | promptOptimizer: boolean;
70 | responseFormat: string;
71 | styleWeight: number;
72 | }
73 |
74 | export interface TTSDefaults {
75 | model: string;
76 | voiceId: string;
77 | speed: number;
78 | volume: number;
79 | pitch: number;
80 | emotion: string;
81 | format: string;
82 | sampleRate: number;
83 | bitrate: number;
84 | channel: number;
85 | }
86 |
87 | // API Configuration
88 | export const API_CONFIG: ApiConfig = {
89 | BASE_URL: 'https://api.minimaxi.com/v1',
90 | ENDPOINTS: {
91 | IMAGE_GENERATION: '/image_generation',
92 | TEXT_TO_SPEECH: '/t2a_v2'
93 | },
94 | HEADERS: {
95 | 'MM-API-Source': 'mcp-tools'
96 | },
97 | TIMEOUT: 30000
98 | } as const;
99 |
100 | // Rate Limiting Configuration
101 | export const RATE_LIMITS: Record<'IMAGE' | 'TTS', RateLimit> = {
102 | IMAGE: { rpm: 10, burst: 3 },
103 | TTS: { rpm: 20, burst: 5 }
104 | } as const;
105 |
106 | // Model Configurations
107 | export const MODELS: Record<'IMAGE' | 'TTS', Record<string, ModelConfig>> = {
108 | IMAGE: {
109 | 'image-01': { name: 'image-01', description: 'Standard image generation' },
110 | 'image-01-live': { name: 'image-01-live', description: 'Live image generation' }
111 | },
112 | TTS: {
113 | 'speech-2.6-hd': { name: 'speech-2.6-hd', description: 'Ultra-low latency, intelligent parsing, and enhanced naturalness' },
114 | 'speech-2.6-turbo': { name: 'speech-2.6-turbo', description: 'Faster, more affordable, ideal for voice agents with 40 languages support' }
115 | }
116 | } as const;
117 |
118 | // Voice Configurations
119 | export const VOICES: Record<string, VoiceConfig> = {
120 | // Basic Chinese voices
121 | 'male-qn-qingse': { name: '青涩青年音色', gender: 'male', language: 'zh' },
122 | 'male-qn-jingying': { name: '精英青年音色', gender: 'male', language: 'zh' },
123 | 'male-qn-badao': { name: '霸道青年音色', gender: 'male', language: 'zh' },
124 | 'male-qn-daxuesheng': { name: '青年大学生音色', gender: 'male', language: 'zh' },
125 | 'female-shaonv': { name: '少女音色', gender: 'female', language: 'zh' },
126 | 'female-yujie': { name: '御姐音色', gender: 'female', language: 'zh' },
127 | 'female-chengshu': { name: '成熟女性音色', gender: 'female', language: 'zh' },
128 | 'female-tianmei': { name: '甜美女性音色', gender: 'female', language: 'zh' },
129 |
130 | // Professional voices
131 | 'presenter_male': { name: '男性主持人', gender: 'male', language: 'zh' },
132 | 'presenter_female': { name: '女性主持人', gender: 'female', language: 'zh' },
133 | 'audiobook_male_1': { name: '男性有声书1', gender: 'male', language: 'zh' },
134 | 'audiobook_male_2': { name: '男性有声书2', gender: 'male', language: 'zh' },
135 | 'audiobook_female_1': { name: '女性有声书1', gender: 'female', language: 'zh' },
136 | 'audiobook_female_2': { name: '女性有声书2', gender: 'female', language: 'zh' },
137 |
138 | // Beta voices
139 | 'male-qn-qingse-jingpin': { name: '青涩青年音色-beta', gender: 'male', language: 'zh' },
140 | 'male-qn-jingying-jingpin': { name: '精英青年音色-beta', gender: 'male', language: 'zh' },
141 | 'male-qn-badao-jingpin': { name: '霸道青年音色-beta', gender: 'male', language: 'zh' },
142 | 'male-qn-daxuesheng-jingpin': { name: '青年大学生音色-beta', gender: 'male', language: 'zh' },
143 | 'female-shaonv-jingpin': { name: '少女音色-beta', gender: 'female', language: 'zh' },
144 | 'female-yujie-jingpin': { name: '御姐音色-beta', gender: 'female', language: 'zh' },
145 | 'female-chengshu-jingpin': { name: '成熟女性音色-beta', gender: 'female', language: 'zh' },
146 | 'female-tianmei-jingpin': { name: '甜美女性音色-beta', gender: 'female', language: 'zh' },
147 |
148 | // Children voices
149 | 'clever_boy': { name: '聪明男童', gender: 'male', language: 'zh' },
150 | 'cute_boy': { name: '可爱男童', gender: 'male', language: 'zh' },
151 | 'lovely_girl': { name: '萌萌女童', gender: 'female', language: 'zh' },
152 | 'cartoon_pig': { name: '卡通猪小琪', gender: 'other', language: 'zh' },
153 |
154 | // Character voices
155 | 'bingjiao_didi': { name: '病娇弟弟', gender: 'male', language: 'zh' },
156 | 'junlang_nanyou': { name: '俊朗男友', gender: 'male', language: 'zh' },
157 | 'chunzhen_xuedi': { name: '纯真学弟', gender: 'male', language: 'zh' },
158 | 'lengdan_xiongzhang': { name: '冷淡学长', gender: 'male', language: 'zh' },
159 | 'badao_shaoye': { name: '霸道少爷', gender: 'male', language: 'zh' },
160 | 'tianxin_xiaoling': { name: '甜心小玲', gender: 'female', language: 'zh' },
161 | 'qiaopi_mengmei': { name: '俏皮萌妹', gender: 'female', language: 'zh' },
162 | 'wumei_yujie': { name: '妩媚御姐', gender: 'female', language: 'zh' },
163 | 'diadia_xuemei': { name: '嗲嗲学妹', gender: 'female', language: 'zh' },
164 | 'danya_xuejie': { name: '淡雅学姐', gender: 'female', language: 'zh' },
165 |
166 | // English voices
167 | 'Santa_Claus': { name: 'Santa Claus', gender: 'male', language: 'en' },
168 | 'Grinch': { name: 'Grinch', gender: 'male', language: 'en' },
169 | 'Rudolph': { name: 'Rudolph', gender: 'other', language: 'en' },
170 | 'Arnold': { name: 'Arnold', gender: 'male', language: 'en' },
171 | 'Charming_Santa': { name: 'Charming Santa', gender: 'male', language: 'en' },
172 | 'Charming_Lady': { name: 'Charming Lady', gender: 'female', language: 'en' },
173 | 'Sweet_Girl': { name: 'Sweet Girl', gender: 'female', language: 'en' },
174 | 'Cute_Elf': { name: 'Cute Elf', gender: 'other', language: 'en' },
175 | 'Attractive_Girl': { name: 'Attractive Girl', gender: 'female', language: 'en' },
176 | 'Serene_Woman': { name: 'Serene Woman', gender: 'female', language: 'en' }
177 | } as const;
178 |
179 | // Parameter Constraints
180 | export const CONSTRAINTS = {
181 | IMAGE: {
182 | PROMPT_MAX_LENGTH: 1500,
183 | MAX_IMAGES: 9,
184 | MIN_DIMENSION: 512,
185 | MAX_DIMENSION: 2048,
186 | DIMENSION_STEP: 8,
187 | ASPECT_RATIOS: ["1:1", "16:9", "4:3", "3:2", "2:3", "3:4", "9:16", "21:9"] as const,
188 | STYLE_TYPES: ["漫画", "元气", "中世纪", "水彩"] as const,
189 | RESPONSE_FORMATS: ["url", "base64"] as const,
190 | SUBJECT_TYPES: ["character"] as const,
191 | STYLE_WEIGHT_MIN: 0.01,
192 | STYLE_WEIGHT_MAX: 1
193 | },
194 | TTS: {
195 | TEXT_MAX_LENGTH: 10000,
196 | SPEED_MIN: 0.5,
197 | SPEED_MAX: 2.0,
198 | VOLUME_MIN: 0.1,
199 | VOLUME_MAX: 10.0,
200 | PITCH_MIN: -12,
201 | PITCH_MAX: 12,
202 | EMOTIONS: ["neutral", "happy", "sad", "angry", "fearful", "disgusted", "surprised"] as const,
203 | FORMATS: ["mp3", "wav", "flac", "pcm"] as const,
204 | SAMPLE_RATES: ["8000", "16000", "22050", "24000", "32000", "44100"] as const,
205 | BITRATES: ["64000", "96000", "128000", "160000", "192000", "224000", "256000", "320000"] as const,
206 | VOICE_MODIFY_PITCH_MIN: -100,
207 | VOICE_MODIFY_PITCH_MAX: 100,
208 | VOICE_MODIFY_INTENSITY_MIN: -100,
209 | VOICE_MODIFY_INTENSITY_MAX: 100,
210 | VOICE_MODIFY_TIMBRE_MIN: -100,
211 | VOICE_MODIFY_TIMBRE_MAX: 100,
212 | SOUND_EFFECTS: ["spacious_echo", "auditorium_echo", "lofi_telephone", "robotic"] as const
213 | }
214 | } as const;
215 |
216 | // Default Values
217 | export const DEFAULTS = {
218 | IMAGE: {
219 | model: 'image-01',
220 | aspectRatio: '1:1',
221 | n: 1,
222 | promptOptimizer: true,
223 | responseFormat: 'url',
224 | styleWeight: 0.8
225 | },
226 | TTS: {
227 | model: 'speech-2.6-hd',
228 | voiceId: 'female-shaonv',
229 | speed: 1.0,
230 | volume: 1.0,
231 | pitch: 0,
232 | emotion: 'neutral',
233 | format: 'mp3',
234 | sampleRate: "32000",
235 | bitrate: "128000",
236 | channel: 1
237 | }
238 | } as const;
239 |
240 | // Type exports for use in other modules
241 | export type ImageModel = keyof typeof MODELS.IMAGE;
242 | export type TTSModel = keyof typeof MODELS.TTS;
243 | export type VoiceId = keyof typeof VOICES;
244 | export type AspectRatio = typeof CONSTRAINTS.IMAGE.ASPECT_RATIOS[number];
245 | export type StyleType = typeof CONSTRAINTS.IMAGE.STYLE_TYPES[number];
246 | export type ResponseFormat = typeof CONSTRAINTS.IMAGE.RESPONSE_FORMATS[number];
247 | export type SubjectType = typeof CONSTRAINTS.IMAGE.SUBJECT_TYPES[number];
248 | export type Emotion = typeof CONSTRAINTS.TTS.EMOTIONS[number];
249 | export type AudioFormat = typeof CONSTRAINTS.TTS.FORMATS[number];
250 | export type SampleRate = typeof CONSTRAINTS.TTS.SAMPLE_RATES[number];
251 | export type Bitrate = typeof CONSTRAINTS.TTS.BITRATES[number];
252 | export type SoundEffect = typeof CONSTRAINTS.TTS.SOUND_EFFECTS[number];
```
--------------------------------------------------------------------------------
/src/config/schemas.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { z } from 'zod';
2 | import {
3 | CONSTRAINTS,
4 | VOICES,
5 | type VoiceId,
6 | type AspectRatio,
7 | type StyleType,
8 | type Emotion,
9 | type AudioFormat,
10 | type SampleRate,
11 | type Bitrate,
12 | type SoundEffect
13 | } from './constants.js';
14 |
15 | // Base schemas
16 | const filePathSchema = z.string().min(1, 'File path is required');
17 | const positiveIntSchema = z.number().int().positive();
18 |
19 | // Helper functions for generating descriptions
20 | const getSoundEffectsDescription = () => {
21 | const descriptions = {
22 | 'spacious_echo': 'spacious_echo (空旷回音)',
23 | 'auditorium_echo': 'auditorium_echo (礼堂广播)',
24 | 'lofi_telephone': 'lofi_telephone (电话失真)',
25 | 'robotic': 'robotic (机械音)'
26 | };
27 | return `Sound effects. Options: ${CONSTRAINTS.TTS.SOUND_EFFECTS.map(effect => descriptions[effect] || effect).join(', ')}. Only one sound effect can be used per request`;
28 | };
29 |
30 | // Image generation schema
31 | export const imageGenerationSchema = z.object({
32 | prompt: z.string()
33 | .min(1, 'Prompt is required')
34 | .max(CONSTRAINTS.IMAGE.PROMPT_MAX_LENGTH, `Prompt must not exceed ${CONSTRAINTS.IMAGE.PROMPT_MAX_LENGTH} characters`),
35 |
36 | outputFile: filePathSchema.describe('Absolute path for generated image'),
37 |
38 |
39 | aspectRatio: z.enum(CONSTRAINTS.IMAGE.ASPECT_RATIOS as readonly [AspectRatio, ...AspectRatio[]])
40 | .default('1:1' as AspectRatio)
41 | .describe(`Aspect ratio for the image. Options: ${CONSTRAINTS.IMAGE.ASPECT_RATIOS.join(', ')}`),
42 |
43 | customSize: z.object({
44 | width: z.number()
45 | .min(CONSTRAINTS.IMAGE.MIN_DIMENSION)
46 | .max(CONSTRAINTS.IMAGE.MAX_DIMENSION)
47 | .multipleOf(CONSTRAINTS.IMAGE.DIMENSION_STEP),
48 | height: z.number()
49 | .min(CONSTRAINTS.IMAGE.MIN_DIMENSION)
50 | .max(CONSTRAINTS.IMAGE.MAX_DIMENSION)
51 | .multipleOf(CONSTRAINTS.IMAGE.DIMENSION_STEP)
52 | }).optional().describe('Custom image dimensions (width x height in pixels). Range: 512-2048, must be multiples of 8. Total resolution should stay under 2M pixels. Only supported with image-01 model (cannot be used with style parameter). When both customSize and aspectRatio are set, aspectRatio takes precedence'),
53 |
54 |
55 | seed: positiveIntSchema.optional().describe('Random seed for reproducible results'),
56 |
57 | subjectReference: z.string().optional().describe('File path to a portrait image for maintaining facial characteristics in generated images. Only supported with image-01 model (cannot be used with style parameter). Provide a clear frontal face photo for best results. Supports local file paths and URLs. Max 10MB, formats: jpg, jpeg, png'),
58 |
59 | style: z.object({
60 | style_type: z.enum(CONSTRAINTS.IMAGE.STYLE_TYPES as readonly [StyleType, ...StyleType[]])
61 | .describe(`Art style type. Options: ${CONSTRAINTS.IMAGE.STYLE_TYPES.join(', ')}`),
62 | style_weight: z.number()
63 | .min(CONSTRAINTS.IMAGE.STYLE_WEIGHT_MIN, 'Style weight must be greater than 0')
64 | .max(CONSTRAINTS.IMAGE.STYLE_WEIGHT_MAX, 'Style weight must not exceed 1')
65 | .default(0.8)
66 | .describe('Style control weight (0-1]. Higher values apply stronger style effects. Default: 0.8')
67 | }).optional().describe('Art style control settings. Uses image-01-live model which does not support customSize or subjectReference parameters. Cannot be combined with customSize or subjectReference'),
68 |
69 | });
70 |
71 | // Text-to-speech schema
72 | export const textToSpeechSchema = z.object({
73 | text: z.string()
74 | .min(1, 'Text is required')
75 | .max(CONSTRAINTS.TTS.TEXT_MAX_LENGTH, `Text to convert to speech. Max ${CONSTRAINTS.TTS.TEXT_MAX_LENGTH} characters. Use newlines for paragraph breaks. For custom pauses, insert <#x#> where x is seconds (0.01-99.99, max 2 decimals). Pause markers must be between pronounceable text and cannot be consecutive`),
76 |
77 | outputFile: filePathSchema.describe('Absolute path for audio file'),
78 |
79 | highQuality: z.boolean()
80 | .default(false)
81 | .describe('Use high-quality model (speech-02-hd) for audiobooks/premium content. Default: false (uses faster speech-02-turbo)'),
82 |
83 | voiceId: z.enum(Object.keys(VOICES) as [VoiceId, ...VoiceId[]])
84 | .default('female-shaonv' as VoiceId)
85 | .describe(`Voice ID for speech generation. Available voices: ${Object.keys(VOICES).map(id => `${id} (${VOICES[id as VoiceId]?.name || id})`).join(', ')}`),
86 |
87 | speed: z.number()
88 | .min(CONSTRAINTS.TTS.SPEED_MIN)
89 | .max(CONSTRAINTS.TTS.SPEED_MAX)
90 | .default(1.0)
91 | .describe(`Speech speed multiplier (${CONSTRAINTS.TTS.SPEED_MIN}-${CONSTRAINTS.TTS.SPEED_MAX}). Higher values = faster speech`),
92 |
93 | volume: z.number()
94 | .min(CONSTRAINTS.TTS.VOLUME_MIN)
95 | .max(CONSTRAINTS.TTS.VOLUME_MAX)
96 | .default(1.0)
97 | .describe(`Audio volume level (${CONSTRAINTS.TTS.VOLUME_MIN}-${CONSTRAINTS.TTS.VOLUME_MAX}). Higher values = louder audio`),
98 |
99 | pitch: z.number()
100 | .min(CONSTRAINTS.TTS.PITCH_MIN)
101 | .max(CONSTRAINTS.TTS.PITCH_MAX)
102 | .default(0)
103 | .describe(`Pitch adjustment in semitones (${CONSTRAINTS.TTS.PITCH_MIN} to ${CONSTRAINTS.TTS.PITCH_MAX}). Negative = lower pitch, Positive = higher pitch`),
104 |
105 | emotion: z.enum(CONSTRAINTS.TTS.EMOTIONS as readonly [Emotion, ...Emotion[]])
106 | .default('neutral' as Emotion)
107 | .describe(`Emotional tone of the speech. Options: ${CONSTRAINTS.TTS.EMOTIONS.join(', ')}`),
108 |
109 | format: z.enum(CONSTRAINTS.TTS.FORMATS as readonly [AudioFormat, ...AudioFormat[]])
110 | .default('mp3' as AudioFormat)
111 | .describe(`Output audio format. Options: ${CONSTRAINTS.TTS.FORMATS.join(', ')}`),
112 |
113 | sampleRate: z.enum(CONSTRAINTS.TTS.SAMPLE_RATES as readonly [SampleRate, ...SampleRate[]])
114 | .default("32000" as SampleRate)
115 | .describe(`Audio sample rate in Hz. Options: ${CONSTRAINTS.TTS.SAMPLE_RATES.join(', ')}`),
116 |
117 | bitrate: z.enum(CONSTRAINTS.TTS.BITRATES as readonly [Bitrate, ...Bitrate[]])
118 | .default("128000" as Bitrate)
119 | .describe(`Audio bitrate in bps. Options: ${CONSTRAINTS.TTS.BITRATES.join(', ')}`),
120 |
121 | languageBoost: z.string().default('auto').describe('Enhance recognition for specific languages/dialects. Options: Chinese, Chinese,Yue, English, Arabic, Russian, Spanish, French, Portuguese, German, Turkish, Dutch, Ukrainian, Vietnamese, Indonesian, Japanese, Italian, Korean, Thai, Polish, Romanian, Greek, Czech, Finnish, Hindi, Bulgarian, Danish, Hebrew, Malay, Persian, Slovak, Swedish, Croatian, Filipino, Hungarian, Norwegian, Slovenian, Catalan, Nynorsk, Tamil, Afrikaans, auto. Use "auto" for automatic detection'),
122 |
123 | intensity: z.number()
124 | .int()
125 | .min(CONSTRAINTS.TTS.VOICE_MODIFY_INTENSITY_MIN)
126 | .max(CONSTRAINTS.TTS.VOICE_MODIFY_INTENSITY_MAX)
127 | .optional()
128 | .describe('Voice intensity adjustment (-100 to 100). Values closer to -100 make voice more robust, closer to 100 make voice softer'),
129 |
130 | timbre: z.number()
131 | .int()
132 | .min(CONSTRAINTS.TTS.VOICE_MODIFY_TIMBRE_MIN)
133 | .max(CONSTRAINTS.TTS.VOICE_MODIFY_TIMBRE_MAX)
134 | .optional()
135 | .describe('Voice timbre adjustment (-100 to 100). Values closer to -100 make voice more mellow, closer to 100 make voice more crisp'),
136 |
137 | sound_effects: z.enum(CONSTRAINTS.TTS.SOUND_EFFECTS as readonly [SoundEffect, ...SoundEffect[]])
138 | .optional()
139 | .describe(getSoundEffectsDescription())
140 | });
141 |
142 | // Task barrier schema
143 | export const taskBarrierSchema = z.object({});
144 |
145 | // Type definitions for parsed schemas
146 | export type ImageGenerationParams = z.infer<typeof imageGenerationSchema>;
147 | export type TextToSpeechParams = z.infer<typeof textToSpeechSchema>;
148 | export type TaskBarrierParams = z.infer<typeof taskBarrierSchema>;
149 |
150 | // MCP Tool Schemas (for registerTool API)
151 | export const imageGenerationToolSchema = {
152 | type: "object",
153 | properties: {
154 | prompt: {
155 | type: "string",
156 | description: `Image generation prompt (max ${CONSTRAINTS.IMAGE.PROMPT_MAX_LENGTH} characters)`,
157 | maxLength: CONSTRAINTS.IMAGE.PROMPT_MAX_LENGTH
158 | },
159 | outputFile: {
160 | type: "string",
161 | description: "Absolute path for generated image file"
162 | },
163 | aspectRatio: {
164 | type: "string",
165 | enum: [...CONSTRAINTS.IMAGE.ASPECT_RATIOS],
166 | default: "1:1",
167 | description: `Aspect ratio for the image. Options: ${CONSTRAINTS.IMAGE.ASPECT_RATIOS.join(', ')}`
168 | },
169 | customSize: {
170 | type: "object",
171 | properties: {
172 | width: { type: "number", minimum: CONSTRAINTS.IMAGE.MIN_DIMENSION, maximum: CONSTRAINTS.IMAGE.MAX_DIMENSION, multipleOf: CONSTRAINTS.IMAGE.DIMENSION_STEP },
173 | height: { type: "number", minimum: CONSTRAINTS.IMAGE.MIN_DIMENSION, maximum: CONSTRAINTS.IMAGE.MAX_DIMENSION, multipleOf: CONSTRAINTS.IMAGE.DIMENSION_STEP }
174 | },
175 | required: ["width", "height"],
176 | description: "Custom image dimensions (width x height in pixels). Range: 512-2048, must be multiples of 8. Total resolution should stay under 2M pixels. Only supported with image-01 model (cannot be used with style parameter). When both customSize and aspectRatio are set, aspectRatio takes precedence"
177 | },
178 | seed: {
179 | type: "number",
180 | description: "Random seed for reproducible results"
181 | },
182 | subjectReference: {
183 | type: "string",
184 | description: "File path to a portrait image for maintaining facial characteristics in generated images. Only supported with image-01 model (cannot be used with style parameter). Provide a clear frontal face photo for best results. Supports local file paths and URLs. Max 10MB, formats: jpg, jpeg, png"
185 | },
186 | style: {
187 | type: "object",
188 | properties: {
189 | style_type: {
190 | type: "string",
191 | enum: [...CONSTRAINTS.IMAGE.STYLE_TYPES],
192 | description: `Art style type. Options: ${CONSTRAINTS.IMAGE.STYLE_TYPES.join(', ')}`
193 | },
194 | style_weight: {
195 | type: "number",
196 | exclusiveMinimum: 0,
197 | maximum: CONSTRAINTS.IMAGE.STYLE_WEIGHT_MAX,
198 | default: 0.8,
199 | description: "Style control weight (0-1]. Higher values apply stronger style effects. Default: 0.8"
200 | }
201 | },
202 | required: ["style_type"],
203 | description: "Art style control settings. Uses image-01-live model which does not support customSize or subjectReference parameters. Cannot be combined with customSize or subjectReference"
204 | }
205 | },
206 | required: ["prompt", "outputFile"]
207 | } as const;
208 |
209 | export const textToSpeechToolSchema = {
210 | type: "object",
211 | properties: {
212 | text: {
213 | type: "string",
214 | description: `Text to convert to speech. Max ${CONSTRAINTS.TTS.TEXT_MAX_LENGTH} characters. Use newlines for paragraph breaks. For custom pauses, insert <#x#> where x is seconds (0.01-99.99, max 2 decimals). Pause markers must be between pronounceable text and cannot be consecutive`,
215 | maxLength: CONSTRAINTS.TTS.TEXT_MAX_LENGTH,
216 | minLength: 1
217 | },
218 | outputFile: {
219 | type: "string",
220 | description: "Absolute path for audio file"
221 | },
222 | highQuality: {
223 | type: "boolean",
224 | default: false,
225 | description: "Use high-quality model (speech-02-hd) for audiobooks/premium content. Default: false (uses faster speech-02-turbo)"
226 | },
227 | voiceId: {
228 | type: "string",
229 | enum: Object.keys(VOICES),
230 | default: "female-shaonv",
231 | description: `Voice ID for speech generation. Available voices: ${Object.keys(VOICES).map(id => `${id} (${VOICES[id as VoiceId]?.name || id})`).join(', ')}`
232 | },
233 | speed: {
234 | type: "number",
235 | minimum: CONSTRAINTS.TTS.SPEED_MIN,
236 | maximum: CONSTRAINTS.TTS.SPEED_MAX,
237 | default: 1.0,
238 | description: `Speech speed multiplier (${CONSTRAINTS.TTS.SPEED_MIN}-${CONSTRAINTS.TTS.SPEED_MAX}). Higher values = faster speech`
239 | },
240 | volume: {
241 | type: "number",
242 | minimum: CONSTRAINTS.TTS.VOLUME_MIN,
243 | maximum: CONSTRAINTS.TTS.VOLUME_MAX,
244 | default: 1.0,
245 | description: `Audio volume level (${CONSTRAINTS.TTS.VOLUME_MIN}-${CONSTRAINTS.TTS.VOLUME_MAX}). Higher values = louder audio`
246 | },
247 | pitch: {
248 | type: "number",
249 | minimum: CONSTRAINTS.TTS.PITCH_MIN,
250 | maximum: CONSTRAINTS.TTS.PITCH_MAX,
251 | default: 0,
252 | description: `Pitch adjustment in semitones (${CONSTRAINTS.TTS.PITCH_MIN} to ${CONSTRAINTS.TTS.PITCH_MAX}). Negative = lower pitch, Positive = higher pitch`
253 | },
254 | emotion: {
255 | type: "string",
256 | enum: [...CONSTRAINTS.TTS.EMOTIONS],
257 | default: "neutral",
258 | description: `Emotional tone of the speech. Options: ${CONSTRAINTS.TTS.EMOTIONS.join(', ')}`
259 | },
260 | format: {
261 | type: "string",
262 | enum: [...CONSTRAINTS.TTS.FORMATS],
263 | default: "mp3",
264 | description: `Output audio format. Options: ${CONSTRAINTS.TTS.FORMATS.join(', ')}`
265 | },
266 | sampleRate: {
267 | type: "string",
268 | enum: [...CONSTRAINTS.TTS.SAMPLE_RATES],
269 | default: "32000",
270 | description: `Audio sample rate in Hz. Options: ${CONSTRAINTS.TTS.SAMPLE_RATES.join(', ')}`
271 | },
272 | bitrate: {
273 | type: "string",
274 | enum: [...CONSTRAINTS.TTS.BITRATES],
275 | default: "128000",
276 | description: `Audio bitrate in bps. Options: ${CONSTRAINTS.TTS.BITRATES.join(', ')}`
277 | },
278 | languageBoost: {
279 | type: "string",
280 | default: "auto",
281 | description: "Enhance recognition for specific languages/dialects. Options: Chinese, Chinese,Yue, English, Arabic, Russian, Spanish, French, Portuguese, German, Turkish, Dutch, Ukrainian, Vietnamese, Indonesian, Japanese, Italian, Korean, Thai, Polish, Romanian, Greek, Czech, Finnish, Hindi, Bulgarian, Danish, Hebrew, Malay, Persian, Slovak, Swedish, Croatian, Filipino, Hungarian, Norwegian, Slovenian, Catalan, Nynorsk, Tamil, Afrikaans, auto. Use 'auto' for automatic detection"
282 | },
283 | intensity: {
284 | type: "number",
285 | minimum: CONSTRAINTS.TTS.VOICE_MODIFY_INTENSITY_MIN,
286 | maximum: CONSTRAINTS.TTS.VOICE_MODIFY_INTENSITY_MAX,
287 | description: "Voice intensity adjustment (-100 to 100). Values closer to -100 make voice more robust, closer to 100 make voice softer"
288 | },
289 | timbre: {
290 | type: "number",
291 | minimum: CONSTRAINTS.TTS.VOICE_MODIFY_TIMBRE_MIN,
292 | maximum: CONSTRAINTS.TTS.VOICE_MODIFY_TIMBRE_MAX,
293 | description: "Voice timbre adjustment (-100 to 100). Values closer to -100 make voice more mellow, closer to 100 make voice more crisp"
294 | },
295 | sound_effects: {
296 | type: "string",
297 | enum: [...CONSTRAINTS.TTS.SOUND_EFFECTS],
298 | description: getSoundEffectsDescription()
299 | }
300 | },
301 | required: ["text", "outputFile"]
302 | } as const;
303 |
304 | export const taskBarrierToolSchema = {
305 | type: "object",
306 | properties: {}
307 | } as const;
308 |
309 | // Validation helper functions
310 | export function validateImageParams(params: unknown): ImageGenerationParams {
311 | try {
312 | const parsed = imageGenerationSchema.parse(params);
313 |
314 | // Manual validation for incompatible parameter combinations
315 | const hasStyle = !!parsed.style;
316 | const hasCustomSize = !!parsed.customSize;
317 | const hasSubjectReference = !!parsed.subjectReference;
318 |
319 | if (hasStyle && hasCustomSize) {
320 | throw new Error('Style parameter (image-01-live model) cannot be combined with customSize (image-01 model feature)');
321 | }
322 |
323 | if (hasStyle && hasSubjectReference) {
324 | throw new Error('Style parameter (image-01-live model) cannot be combined with subjectReference (image-01 model feature)');
325 | }
326 |
327 | return parsed;
328 | } catch (error) {
329 | if (error instanceof z.ZodError) {
330 | const messages = error.errors.map(e => `${e.path.join('.')}: ${e.message}`);
331 | throw new Error(`Validation failed: ${messages.join(', ')}`);
332 | }
333 | throw error;
334 | }
335 | }
336 |
337 | export function validateTTSParams(params: unknown): TextToSpeechParams {
338 | try {
339 | return textToSpeechSchema.parse(params);
340 | } catch (error) {
341 | if (error instanceof z.ZodError) {
342 | const messages = error.errors.map(e => `${e.path.join('.')}: ${e.message}`);
343 | throw new Error(`Validation failed: ${messages.join(', ')}`);
344 | }
345 | throw error;
346 | }
347 | }
348 |
349 | export function validateTaskBarrierParams(params: unknown): TaskBarrierParams {
350 | try {
351 | return taskBarrierSchema.parse(params);
352 | } catch (error) {
353 | if (error instanceof z.ZodError) {
354 | const messages = error.errors.map(e => `${e.path.join('.')}: ${e.message}`);
355 | throw new Error(`Validation failed: ${messages.join(', ')}`);
356 | }
357 | throw error;
358 | }
359 | }
```