Initial commit: Suno MCP server with 24 tools, proxy support, and opencode skill

This commit is contained in:
OpenCode
2026-07-13 11:31:28 +07:00
commit 10548187f4
32 changed files with 4031 additions and 0 deletions
+183
View File
@@ -0,0 +1,183 @@
/**
* Suno API HTTP client with rate limiting and polling helpers.
*
* Rate limit: 20 requests per 10 seconds (sunoapi.org).
* Proxy support via SUNO_HTTP_PROXY / HTTPS_PROXY / HTTP_PROXY env vars.
*/
import { ProxyAgent } from 'undici';
export interface SunoApiResponse<T = unknown> {
code: number;
msg: string;
data: T;
}
export interface SunoTrack {
id: string;
audio_url?: string;
audioUrl?: string;
source_audio_url?: string;
stream_audio_url?: string;
streamAudioUrl?: string;
source_stream_audio_url?: string;
image_url?: string;
imageUrl?: string;
source_image_url?: string;
prompt?: string;
model_name?: string;
modelName?: string;
title?: string;
tags?: string;
createTime?: string;
duration?: number;
}
export interface SunoTaskData {
taskId: string;
parentMusicId?: string;
param?: string;
response?: {
taskId?: string;
data?: SunoTrack[];
sunoData?: SunoTrack[];
};
status: string;
type?: string;
operationType?: 'generate' | 'extend' | 'upload_cover' | 'upload_extend';
errorCode?: number | null;
errorMessage?: string | null;
}
function getProxyUrl(): string | undefined {
return (
process.env.SUNO_HTTP_PROXY ||
process.env.HTTPS_PROXY ||
process.env.https_proxy ||
process.env.HTTP_PROXY ||
process.env.http_proxy
);
}
export class SunoClient {
private readonly baseUrl = 'https://api.sunoapi.org/api/v1';
private readonly apiKey: string;
private readonly maxRequests = 20;
private readonly windowMs = 10_000;
private readonly proxyAgent?: ProxyAgent;
private requestTimestamps: number[] = [];
constructor(apiKey: string) {
this.apiKey = apiKey;
const proxyUrl = getProxyUrl();
if (proxyUrl) {
this.proxyAgent = new ProxyAgent(proxyUrl);
}
}
/**
* Enforce rate limit: max 20 requests per 10 seconds.
*/
private async throttle(): Promise<void> {
const now = Date.now();
this.requestTimestamps = this.requestTimestamps.filter(
(ts) => now - ts < this.windowMs
);
if (this.requestTimestamps.length >= this.maxRequests) {
const oldest = this.requestTimestamps[0];
const wait = this.windowMs - (now - oldest);
if (wait > 0) {
await new Promise((resolve) => setTimeout(resolve, wait));
return this.throttle();
}
}
this.requestTimestamps.push(now);
}
async request<T = unknown>(
method: 'GET' | 'POST',
path: string,
body?: Record<string, unknown>
): Promise<SunoApiResponse<T>> {
await this.throttle();
const url = path.startsWith('http') ? path : `${this.baseUrl}${path}`;
const headers: Record<string, string> = {
Authorization: `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
};
const fetchOptions: RequestInit & { dispatcher?: any } = {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
};
if (this.proxyAgent) {
fetchOptions.dispatcher = this.proxyAgent;
}
const response = await fetch(url, fetchOptions);
const text = await response.text();
let json: SunoApiResponse<T>;
try {
json = JSON.parse(text) as SunoApiResponse<T>;
} catch {
throw new Error(`Invalid JSON response from Suno API: ${text}`);
}
if (json.code !== 200) {
throw new Error(`Suno API error ${json.code}: ${json.msg || 'unknown error'}`);
}
return json;
}
get<T = unknown>(path: string): Promise<SunoApiResponse<T>> {
return this.request<T>('GET', path);
}
post<T = unknown>(path: string, body: Record<string, unknown>): Promise<SunoApiResponse<T>> {
return this.request<T>('POST', path, body);
}
/**
* Poll task status until terminal state or timeout.
*/
async pollTaskStatus(
taskId: string,
options: {
intervalMs?: number;
maxAttempts?: number;
statusPath?: string;
} = {}
): Promise<SunoTaskData> {
const intervalMs = options.intervalMs ?? 15_000;
const maxAttempts = options.maxAttempts ?? 60;
const statusPath = options.statusPath ?? `/generate/record-info?taskId=${encodeURIComponent(taskId)}`;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const result = await this.get<SunoTaskData>(statusPath);
const status = result.data.status;
if (
status === 'SUCCESS' ||
status === 'FAILED' ||
status === 'CREATE_TASK_FAILED' ||
status === 'GENERATE_AUDIO_FAILED' ||
status === 'CALLBACK_EXCEPTION' ||
status === 'SENSITIVE_WORD_ERROR'
) {
return result.data;
}
if (attempt < maxAttempts) {
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
}
throw new Error(`Polling timeout for task ${taskId} after ${maxAttempts} attempts`);
}
}
+405
View File
@@ -0,0 +1,405 @@
import { SunoClient, SunoTaskData } from './client';
import * as schemas from './schemas';
const ASYNC_POLLING_PATHS: Record<string, string> = {
'suno_generate_music': '/generate/record-info?taskId=',
'suno_extend_music': '/generate/record-info?taskId=',
'suno_replace_section': '/generate/record-info?taskId=',
'suno_upload_and_cover': '/generate/record-info?taskId=',
'suno_upload_and_extend': '/generate/record-info?taskId=',
'suno_add_vocals': '/generate/record-info?taskId=',
'suno_add_instrumental': '/generate/record-info?taskId=',
'suno_generate_mashup': '/generate/record-info?taskId=',
'suno_separate_vocals': '/vocal-removal/record-info?taskId=',
'suno_separate_stems': '/vocal-removal/record-info?taskId=',
'suno_create_video': '/generate/video-info?taskId=',
'suno_create_cover': '/generate/cover-info?taskId=',
'suno_convert_to_wav': '/generate/wav-info?taskId=',
'suno_generate_sounds': '/generate/record-info?taskId=',
};
function formatTaskResult(data: SunoTaskData): string {
const status = data.status;
if (status !== 'SUCCESS') {
return JSON.stringify({
taskId: data.taskId,
status,
errorCode: data.errorCode,
errorMessage: data.errorMessage,
}, null, 2);
}
const response = data.response;
const tracks = response?.data ?? response?.sunoData ?? [];
return JSON.stringify(
{
taskId: data.taskId,
status,
operationType: data.operationType,
tracks: tracks.map((t) => ({
id: t.id,
title: t.title,
tags: t.tags,
duration: t.duration,
prompt: t.prompt,
audio_url: t.audio_url ?? t.audioUrl,
stream_audio_url: t.stream_audio_url ?? t.streamAudioUrl,
image_url: t.image_url ?? t.imageUrl,
})),
},
null,
2
);
}
async function runAsyncTask(
client: SunoClient,
taskName: string,
createPath: string,
payload: Record<string, unknown>
): Promise<string> {
const createResult = await client.post<{ taskId: string }>(createPath, payload);
const taskId = createResult.data.taskId;
const statusPathTemplate = ASYNC_POLLING_PATHS[taskName];
if (!statusPathTemplate) {
return JSON.stringify({ taskId, status: 'PENDING', note: 'Use suno_get_task_status to poll.' }, null, 2);
}
const statusData = await client.pollTaskStatus(taskId, {
statusPath: `${statusPathTemplate}${encodeURIComponent(taskId)}`,
});
return formatTaskResult(statusData);
}
export class ToolHandlers {
constructor(private readonly client: SunoClient) {}
async handle(name: string, args: unknown): Promise<{ content: { type: string; text: string }[] }> {
let resultText: string;
switch (name) {
case 'suno_generate_music': {
const input = schemas.GenerateMusicSchema.parse(args);
const payload: Record<string, unknown> = {
prompt: input.prompt,
customMode: input.customMode,
instrumental: input.instrumental,
model: input.model,
callBackUrl: input.callBackUrl ?? '',
};
if (input.style !== undefined) payload.style = input.style;
if (input.title !== undefined) payload.title = input.title;
if (input.negativeTags !== undefined) payload.negativeTags = input.negativeTags;
if (input.vocalGender !== undefined) payload.vocalGender = input.vocalGender;
if (input.styleWeight !== undefined) payload.styleWeight = input.styleWeight;
if (input.weirdnessConstraint !== undefined) payload.weirdnessConstraint = input.weirdnessConstraint;
if (input.audioWeight !== undefined) payload.audioWeight = input.audioWeight;
if (input.personaId !== undefined) payload.personaId = input.personaId;
if (input.personaModel !== undefined) payload.personaModel = input.personaModel;
resultText = await runAsyncTask(this.client, name, '/generate', payload);
break;
}
case 'suno_generate_lyrics': {
const input = schemas.GenerateLyricsSchema.parse(args);
const result = await this.client.post<{ taskId: string }>('/lyrics', {
prompt: input.prompt,
callBackUrl: input.callBackUrl ?? '',
});
resultText = JSON.stringify({ taskId: result.data.taskId, status: 'PENDING' }, null, 2);
break;
}
case 'suno_generate_sounds': {
const input = schemas.GenerateSoundsSchema.parse(args);
const payload: Record<string, unknown> = {
prompt: input.prompt,
model: input.model,
callBackUrl: input.callBackUrl ?? '',
};
if (input.soundLoop !== undefined) payload.soundLoop = input.soundLoop;
if (input.soundTempo !== undefined) payload.soundTempo = input.soundTempo;
if (input.soundKey !== undefined) payload.soundKey = input.soundKey;
if (input.grabLyrics !== undefined) payload.grabLyrics = input.grabLyrics;
resultText = await runAsyncTask(this.client, name, '/generate/sounds', payload);
break;
}
case 'suno_extend_music': {
const input = schemas.ExtendMusicSchema.parse(args);
const payload: Record<string, unknown> = {
audioId: input.audioId,
defaultParamFlag: input.defaultParamFlag,
model: input.model,
callBackUrl: input.callBackUrl ?? '',
};
if (input.prompt !== undefined) payload.prompt = input.prompt;
if (input.style !== undefined) payload.style = input.style;
if (input.title !== undefined) payload.title = input.title;
if (input.continueAt !== undefined) payload.continueAt = input.continueAt;
if (input.negativeTags !== undefined) payload.negativeTags = input.negativeTags;
if (input.vocalGender !== undefined) payload.vocalGender = input.vocalGender;
if (input.styleWeight !== undefined) payload.styleWeight = input.styleWeight;
if (input.weirdnessConstraint !== undefined) payload.weirdnessConstraint = input.weirdnessConstraint;
if (input.audioWeight !== undefined) payload.audioWeight = input.audioWeight;
if (input.personaId !== undefined) payload.personaId = input.personaId;
if (input.personaModel !== undefined) payload.personaModel = input.personaModel;
resultText = await runAsyncTask(this.client, name, '/generate/extend', payload);
break;
}
case 'suno_replace_section': {
const input = schemas.ReplaceSectionSchema.parse(args);
const payload: Record<string, unknown> = {
audioId: input.audioId,
prompt: input.prompt,
start: input.start,
end: input.end,
model: input.model,
callBackUrl: input.callBackUrl ?? '',
};
if (input.replaceAudioUrl !== undefined) payload.replaceAudioUrl = input.replaceAudioUrl;
if (input.customMode !== undefined) payload.customMode = input.customMode;
if (input.style !== undefined) payload.style = input.style;
if (input.title !== undefined) payload.title = input.title;
if (input.instrumental !== undefined) payload.instrumental = input.instrumental;
resultText = await runAsyncTask(this.client, name, '/generate/replace-section', payload);
break;
}
case 'suno_add_vocals': {
const input = schemas.AddVocalsSchema.parse(args);
const payload: Record<string, unknown> = {
audioId: input.audioId,
prompt: input.prompt,
model: input.model,
callBackUrl: input.callBackUrl ?? '',
};
if (input.customMode !== undefined) payload.customMode = input.customMode;
if (input.style !== undefined) payload.style = input.style;
if (input.title !== undefined) payload.title = input.title;
resultText = await runAsyncTask(this.client, name, '/generate/add-vocals', payload);
break;
}
case 'suno_add_instrumental': {
const input = schemas.AddInstrumentalSchema.parse(args);
const payload: Record<string, unknown> = {
audioId: input.audioId,
prompt: input.prompt,
model: input.model,
callBackUrl: input.callBackUrl ?? '',
};
if (input.customMode !== undefined) payload.customMode = input.customMode;
if (input.style !== undefined) payload.style = input.style;
if (input.title !== undefined) payload.title = input.title;
resultText = await runAsyncTask(this.client, name, '/generate/add-instrumental', payload);
break;
}
case 'suno_upload_and_cover': {
const input = schemas.UploadAndCoverSchema.parse(args);
const payload: Record<string, unknown> = {
uploadUrl: input.uploadUrl,
customMode: input.customMode,
instrumental: input.instrumental,
model: input.model,
callBackUrl: input.callBackUrl ?? '',
};
if (input.prompt !== undefined) payload.prompt = input.prompt;
if (input.style !== undefined) payload.style = input.style;
if (input.title !== undefined) payload.title = input.title;
if (input.negativeTags !== undefined) payload.negativeTags = input.negativeTags;
if (input.vocalGender !== undefined) payload.vocalGender = input.vocalGender;
if (input.styleWeight !== undefined) payload.styleWeight = input.styleWeight;
if (input.weirdnessConstraint !== undefined) payload.weirdnessConstraint = input.weirdnessConstraint;
if (input.audioWeight !== undefined) payload.audioWeight = input.audioWeight;
if (input.personaId !== undefined) payload.personaId = input.personaId;
if (input.personaModel !== undefined) payload.personaModel = input.personaModel;
resultText = await runAsyncTask(this.client, name, '/generate/upload-cover', payload);
break;
}
case 'suno_upload_and_extend': {
const input = schemas.UploadAndExtendSchema.parse(args);
const payload: Record<string, unknown> = {
uploadUrl: input.uploadUrl,
defaultParamFlag: input.defaultParamFlag,
model: input.model,
callBackUrl: input.callBackUrl ?? '',
};
if (input.prompt !== undefined) payload.prompt = input.prompt;
if (input.style !== undefined) payload.style = input.style;
if (input.title !== undefined) payload.title = input.title;
if (input.continueAt !== undefined) payload.continueAt = input.continueAt;
resultText = await runAsyncTask(this.client, name, '/generate/upload-extend', payload);
break;
}
case 'suno_generate_mashup': {
const input = schemas.GenerateMashupSchema.parse(args);
const payload: Record<string, unknown> = {
uploadUrlList: input.uploadUrlList,
customMode: input.customMode,
prompt: input.prompt,
model: input.model,
callBackUrl: input.callBackUrl ?? '',
};
if (input.instrumental !== undefined) payload.instrumental = input.instrumental;
if (input.style !== undefined) payload.style = input.style;
if (input.title !== undefined) payload.title = input.title;
if (input.negativeTags !== undefined) payload.negativeTags = input.negativeTags;
if (input.vocalGender !== undefined) payload.vocalGender = input.vocalGender;
if (input.styleWeight !== undefined) payload.styleWeight = input.styleWeight;
if (input.weirdnessConstraint !== undefined) payload.weirdnessConstraint = input.weirdnessConstraint;
if (input.audioWeight !== undefined) payload.audioWeight = input.audioWeight;
resultText = await runAsyncTask(this.client, name, '/generate/mashup', payload);
break;
}
case 'suno_separate_vocals': {
const input = schemas.SeparateVocalsSchema.parse(args);
resultText = await runAsyncTask(this.client, name, '/vocal-removal/generate', {
taskId: input.taskId,
audioId: input.audioId,
callBackUrl: input.callBackUrl ?? '',
});
break;
}
case 'suno_separate_stems': {
const input = schemas.SeparateStemsSchema.parse(args);
resultText = await runAsyncTask(this.client, name, '/vocal-removal/generate', {
taskId: input.taskId,
audioId: input.audioId,
type: 'split_stem',
callBackUrl: input.callBackUrl ?? '',
});
break;
}
case 'suno_create_video': {
const input = schemas.CreateVideoSchema.parse(args);
const payload: Record<string, unknown> = {
taskId: input.taskId,
audioId: input.audioId,
callBackUrl: input.callBackUrl ?? '',
};
if (input.author !== undefined) payload.author = input.author;
if (input.domainName !== undefined) payload.domainName = input.domainName;
resultText = await runAsyncTask(this.client, name, '/generate/video', payload);
break;
}
case 'suno_create_cover': {
const input = schemas.CreateCoverSchema.parse(args);
resultText = await runAsyncTask(this.client, name, '/generate/cover', {
taskId: input.taskId,
audioId: input.audioId,
callBackUrl: input.callBackUrl ?? '',
});
break;
}
case 'suno_convert_to_wav': {
const input = schemas.ConvertToWavSchema.parse(args);
resultText = await runAsyncTask(this.client, name, '/generate/wav', {
taskId: input.taskId,
audioId: input.audioId,
callBackUrl: input.callBackUrl ?? '',
});
break;
}
case 'suno_boost_style': {
const input = schemas.BoostStyleSchema.parse(args);
const result = await this.client.post<{ result: string }>('/style/generate', {
content: input.content,
});
resultText = result.data.result;
break;
}
case 'suno_generate_persona': {
const input = schemas.GeneratePersonaSchema.parse(args);
const payload: Record<string, unknown> = { taskId: input.taskId };
if (input.audioId !== undefined) payload.audioId = input.audioId;
const result = await this.client.post<{ personaId?: string }>('/generate/persona', payload);
resultText = JSON.stringify(result.data, null, 2);
break;
}
case 'suno_get_task_status': {
const input = schemas.GetTaskStatusSchema.parse(args);
const data = await this.client.get<SunoTaskData>(
`/generate/record-info?taskId=${encodeURIComponent(input.taskId)}`
);
resultText = formatTaskResult(data.data);
break;
}
case 'suno_get_timestamped_lyrics': {
const input = schemas.GetTimestampedLyricsSchema.parse(args);
const result = await this.client.get<unknown>(
`/generate/timestamped-lyrics?taskId=${encodeURIComponent(input.taskId)}&audioId=${encodeURIComponent(
input.audioId
)}`
);
resultText = JSON.stringify(result.data, null, 2);
break;
}
case 'suno_get_lyrics_details': {
const input = schemas.GetLyricsDetailsSchema.parse(args);
const result = await this.client.get<unknown>(
`/generate/lyrics-info?taskId=${encodeURIComponent(input.taskId)}`
);
resultText = JSON.stringify(result.data, null, 2);
break;
}
case 'suno_get_wav_details': {
const input = schemas.GetWavDetailsSchema.parse(args);
const result = await this.client.get<unknown>(
`/generate/wav-info?taskId=${encodeURIComponent(input.taskId)}`
);
resultText = JSON.stringify(result.data, null, 2);
break;
}
case 'suno_get_vocal_separation_details': {
const input = schemas.GetVocalSeparationDetailsSchema.parse(args);
const result = await this.client.get<unknown>(
`/vocal-removal/record-info?taskId=${encodeURIComponent(input.taskId)}`
);
resultText = JSON.stringify(result.data, null, 2);
break;
}
case 'suno_get_video_details': {
const input = schemas.GetVideoDetailsSchema.parse(args);
const result = await this.client.get<unknown>(
`/generate/video-info?taskId=${encodeURIComponent(input.taskId)}`
);
resultText = JSON.stringify(result.data, null, 2);
break;
}
case 'suno_get_credits': {
schemas.GetCreditsSchema.parse(args);
const result = await this.client.get<number>('/generate/credit');
resultText = JSON.stringify({ credits: result.data }, null, 2);
break;
}
default:
throw new Error(`Unknown tool: ${name}`);
}
return {
content: [{ type: 'text', text: resultText }],
};
}
}
+59
View File
@@ -0,0 +1,59 @@
#!/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 { SunoClient } from './client.js';
import { ToolHandlers } from './handlers.js';
import { TOOLS } from './tools.js';
function main(): void {
const apiKey = process.env.SUNO_API_KEY;
if (!apiKey) {
console.error('SUNO_API_KEY environment variable is required');
process.exit(1);
}
const client = new SunoClient(apiKey);
const handlers = new ToolHandlers(client);
const server = new Server(
{
name: 'suno-mcp-server',
version: '1.0.0',
},
{
capabilities: {
tools: {},
},
}
);
server.setRequestHandler(ListToolsRequestSchema, async () => {
return { tools: TOOLS as Tool[] };
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
const result = await handlers.handle(request.params.name, request.params.arguments ?? {});
return result;
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return {
content: [{ type: 'text', text: `Error: ${message}` }],
isError: true,
};
}
});
const transport = new StdioServerTransport();
server.connect(transport).catch((error: unknown) => {
console.error('Failed to start MCP server:', error);
process.exit(1);
});
}
main();
+262
View File
@@ -0,0 +1,262 @@
import { z } from 'zod';
const MODELS: [string, ...string[]] = ['V4', 'V4_5', 'V4_5PLUS', 'V4_5ALL', 'V5', 'V5_5'];
const SOUND_MODELS: [string, ...string[]] = ['V5'];
const ModelEnum = z.enum(MODELS).describe('Model version (V4, V4_5, V4_5PLUS, V4_5ALL, V5, V5_5)');
const SoundModelEnum = z.enum(SOUND_MODELS).describe('Sounds model (V5 only)');
const PersonaModelEnum = z.enum(['style_persona', 'voice_persona']).optional();
const CallbackUrl = z.string().optional().describe('Optional webhook URL for async completion');
const CommonGenerateParams = {
model: ModelEnum,
negativeTags: z.string().optional().describe('Styles to exclude'),
vocalGender: z.enum(['m', 'f']).optional().describe('Preferred vocal gender'),
styleWeight: z.number().min(0).max(1).optional().describe('Style weight 0.00-1.00'),
weirdnessConstraint: z.number().min(0).max(1).optional().describe('Weirdness constraint 0.00-1.00'),
audioWeight: z.number().min(0).max(1).optional().describe('Audio influence weight 0.00-1.00'),
personaId: z.string().optional().describe('Persona or voice ID'),
personaModel: PersonaModelEnum.describe('Use style_persona or voice_persona'),
callBackUrl: CallbackUrl,
};
// 1. Generate Music (12 credits)
export const GenerateMusicSchema = z.object({
prompt: z.string().optional().describe('Music description / lyrics (max 500 chars non-custom, 3000-5000 custom mode; required when customMode=false or customMode=true with instrumental=false)'),
customMode: z.boolean().describe('Enable custom mode (requires style and title)'),
instrumental: z.boolean().describe('Generate instrumental without vocals'),
style: z.string().optional().describe('Music style/genre (required when customMode=true)'),
title: z.string().optional().describe('Song title (required when customMode=true)'),
...CommonGenerateParams,
}).strict();
// 2. Generate Lyrics (0.4 credits)
export const GenerateLyricsSchema = z.object({
prompt: z.string().describe('Topic or theme for lyrics'),
callBackUrl: CallbackUrl,
}).strict();
// 3. Generate Sounds (2.5 credits)
export const GenerateSoundsSchema = z.object({
prompt: z.string().max(500).describe('Sound description (max 500 chars)'),
model: SoundModelEnum,
soundLoop: z.boolean().optional().describe('Enable loop playback'),
soundTempo: z.number().int().min(1).max(300).optional().describe('BPM'),
soundKey: z.enum([
'Any', 'Cm', 'C#m', 'Dm', 'D#m', 'Em', 'Fm', 'F#m', 'Gm', 'G#m', 'Am', 'A#m', 'Bm',
'C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'
]).optional().describe('Pitch key'),
grabLyrics: z.boolean().optional().describe('Fetch lyric subtitles'),
callBackUrl: CallbackUrl,
}).strict();
// 4. Extend Music (12 credits)
export const ExtendMusicSchema = z.object({
audioId: z.string().describe('ID of the source track to extend'),
defaultParamFlag: z.boolean().describe('Use custom params (true) or source params (false)'),
prompt: z.string().optional().describe('Extension description (required with custom params)'),
style: z.string().optional().describe('Style (required with custom params)'),
title: z.string().optional().describe('Title (required with custom params)'),
continueAt: z.number().positive().optional().describe('Start time in seconds (required with custom params)'),
model: ModelEnum,
negativeTags: z.string().optional(),
vocalGender: z.enum(['m', 'f']).optional(),
styleWeight: z.number().min(0).max(1).optional(),
weirdnessConstraint: z.number().min(0).max(1).optional(),
audioWeight: z.number().min(0).max(1).optional(),
personaId: z.string().optional(),
personaModel: PersonaModelEnum,
callBackUrl: CallbackUrl,
}).strict();
// 5. Replace Section (5 credits)
export const ReplaceSectionSchema = z.object({
audioId: z.string().describe('ID of track to modify (or taskId if replacing within own generated track)'),
prompt: z.string().describe('Description for replacement section'),
replaceAudioUrl: z.string().optional().describe('Custom uploaded audio URL for replacement'),
start: z.number().nonnegative().describe('Start time in seconds'),
end: z.number().positive().describe('End time in seconds'),
customMode: z.boolean().optional().describe('Enable custom mode'),
style: z.string().optional().describe('Style (custom mode)'),
title: z.string().optional().describe('Title (custom mode)'),
instrumental: z.boolean().optional().describe('Instrumental (custom mode)'),
model: ModelEnum,
callBackUrl: CallbackUrl,
}).strict();
// 6. Add Vocals (12 credits)
export const AddVocalsSchema = z.object({
audioId: z.string().describe('Instrumental track ID'),
prompt: z.string().optional().describe('Vocal/lyrics concept'),
customMode: z.boolean().optional(),
style: z.string().optional(),
title: z.string().optional(),
model: ModelEnum,
callBackUrl: CallbackUrl,
}).strict();
// 7. Add Instrumental (12 credits)
export const AddInstrumentalSchema = z.object({
audioId: z.string().describe('Vocal or melody track ID'),
prompt: z.string().optional().describe('Desired backing arrangement'),
customMode: z.boolean().optional(),
style: z.string().optional(),
title: z.string().optional(),
model: ModelEnum,
callBackUrl: CallbackUrl,
}).strict();
// 8. Upload and Cover (12 credits)
export const UploadAndCoverSchema = z.object({
uploadUrl: z.string().url().describe('Public URL of audio file to cover (max 8 minutes)'),
prompt: z.string().optional(),
customMode: z.boolean(),
instrumental: z.boolean(),
style: z.string().optional(),
title: z.string().optional(),
model: ModelEnum,
negativeTags: z.string().optional(),
vocalGender: z.enum(['m', 'f']).optional(),
styleWeight: z.number().min(0).max(1).optional(),
weirdnessConstraint: z.number().min(0).max(1).optional(),
audioWeight: z.number().min(0).max(1).optional(),
personaId: z.string().optional(),
personaModel: PersonaModelEnum,
callBackUrl: CallbackUrl,
}).strict();
// 9. Upload and Extend (12 credits)
export const UploadAndExtendSchema = z.object({
uploadUrl: z.string().url().describe('Public URL of audio file to extend'),
defaultParamFlag: z.boolean(),
prompt: z.string().optional(),
style: z.string().optional(),
title: z.string().optional(),
continueAt: z.number().positive().optional(),
model: ModelEnum,
callBackUrl: CallbackUrl,
}).strict();
// 10. Separate Vocals (10 credits)
export const SeparateVocalsSchema = z.object({
taskId: z.string().describe('Original generation task ID'),
audioId: z.string().describe('Track ID to separate'),
callBackUrl: CallbackUrl,
}).strict();
// 11. Separate Stems / Advanced (20 credits)
export const SeparateStemsSchema = z.object({
taskId: z.string().describe('Original generation task ID'),
audioId: z.string().describe('Track ID to separate into stems'),
callBackUrl: CallbackUrl,
}).strict();
// 12. Create Music Video (2 credits)
export const CreateVideoSchema = z.object({
taskId: z.string().describe('Music generation task ID'),
audioId: z.string().describe('Track ID for the video'),
author: z.string().optional().describe('Artist name'),
domainName: z.string().optional().describe('Brand domain'),
callBackUrl: CallbackUrl,
}).strict();
// 13. Create Music Cover (0 credits)
export const CreateCoverSchema = z.object({
taskId: z.string().describe('Music generation task ID'),
audioId: z.string().describe('Track ID for cover image'),
callBackUrl: CallbackUrl,
}).strict();
// 14. Convert to WAV (0.4 credits)
export const ConvertToWavSchema = z.object({
taskId: z.string().describe('Music generation task ID'),
audioId: z.string().describe('Track ID to convert'),
callBackUrl: CallbackUrl,
}).strict();
// 15. Boost Style (0.4 credits)
export const BoostStyleSchema = z.object({
content: z.string().describe('Short style description, e.g. "Pop, Mysterious"'),
}).strict();
// 16. Generate Persona (0 credits)
export const GeneratePersonaSchema = z.object({
taskId: z.string().describe('Music generation task ID to derive persona from'),
audioId: z.string().describe('Track ID to derive persona from'),
}).strict();
// 17. Generate Mashup (12 credits)
export const GenerateMashupSchema = z.object({
uploadUrlList: z.array(z.string().url()).length(2).describe('Exactly 2 audio URLs to mash up'),
customMode: z.boolean(),
instrumental: z.boolean().optional(),
prompt: z.string().describe('Mashup concept / lyrics'),
style: z.string().optional(),
title: z.string().optional(),
model: z.enum(['V4', 'V4_5', 'V4_5PLUS', 'V4_5ALL', 'V5']),
negativeTags: z.string().optional(),
vocalGender: z.enum(['m', 'f']).optional(),
styleWeight: z.number().min(0).max(1).optional(),
weirdnessConstraint: z.number().min(0).max(1).optional(),
audioWeight: z.number().min(0).max(1).optional(),
callBackUrl: CallbackUrl,
}).strict();
// 18. Get Task Status (0 credits)
export const GetTaskStatusSchema = z.object({
taskId: z.string().describe('Task ID to check'),
}).strict();
// 19. Get Timestamped Lyrics (0.5 credits)
export const GetTimestampedLyricsSchema = z.object({
taskId: z.string().describe('Music generation task ID'),
audioId: z.string().describe('Track ID'),
}).strict();
// 20. Get Lyrics Details (0 credits)
export const GetLyricsDetailsSchema = z.object({
taskId: z.string().describe('Lyrics generation task ID'),
}).strict();
// 21. Get WAV Details (0 credits)
export const GetWavDetailsSchema = z.object({
taskId: z.string().describe('WAV conversion task ID'),
}).strict();
// 22. Get Vocal Separation Details (0 credits)
export const GetVocalSeparationDetailsSchema = z.object({
taskId: z.string().describe('Vocal separation task ID'),
}).strict();
// 23. Get Video Details (0 credits)
export const GetVideoDetailsSchema = z.object({
taskId: z.string().describe('Music video task ID'),
}).strict();
// 24. Get Credits (0 credits)
export const GetCreditsSchema = z.object({}).strict();
export type GenerateMusicInput = z.infer<typeof GenerateMusicSchema>;
export type GenerateLyricsInput = z.infer<typeof GenerateLyricsSchema>;
export type GenerateSoundsInput = z.infer<typeof GenerateSoundsSchema>;
export type ExtendMusicInput = z.infer<typeof ExtendMusicSchema>;
export type ReplaceSectionInput = z.infer<typeof ReplaceSectionSchema>;
export type AddVocalsInput = z.infer<typeof AddVocalsSchema>;
export type AddInstrumentalInput = z.infer<typeof AddInstrumentalSchema>;
export type UploadAndCoverInput = z.infer<typeof UploadAndCoverSchema>;
export type UploadAndExtendInput = z.infer<typeof UploadAndExtendSchema>;
export type SeparateVocalsInput = z.infer<typeof SeparateVocalsSchema>;
export type SeparateStemsInput = z.infer<typeof SeparateStemsSchema>;
export type CreateVideoInput = z.infer<typeof CreateVideoSchema>;
export type CreateCoverInput = z.infer<typeof CreateCoverSchema>;
export type ConvertToWavInput = z.infer<typeof ConvertToWavSchema>;
export type BoostStyleInput = z.infer<typeof BoostStyleSchema>;
export type GeneratePersonaInput = z.infer<typeof GeneratePersonaSchema>;
export type GenerateMashupInput = z.infer<typeof GenerateMashupSchema>;
export type GetTaskStatusInput = z.infer<typeof GetTaskStatusSchema>;
export type GetTimestampedLyricsInput = z.infer<typeof GetTimestampedLyricsSchema>;
export type GetLyricsDetailsInput = z.infer<typeof GetLyricsDetailsSchema>;
export type GetWavDetailsInput = z.infer<typeof GetWavDetailsSchema>;
export type GetVocalSeparationDetailsInput = z.infer<typeof GetVocalSeparationDetailsSchema>;
export type GetVideoDetailsInput = z.infer<typeof GetVideoDetailsSchema>;
+170
View File
@@ -0,0 +1,170 @@
import { zodToJsonSchema } from 'zod-to-json-schema';
import {
GenerateMusicSchema,
GenerateLyricsSchema,
GenerateSoundsSchema,
ExtendMusicSchema,
ReplaceSectionSchema,
AddVocalsSchema,
AddInstrumentalSchema,
UploadAndCoverSchema,
UploadAndExtendSchema,
SeparateVocalsSchema,
SeparateStemsSchema,
CreateVideoSchema,
CreateCoverSchema,
ConvertToWavSchema,
BoostStyleSchema,
GeneratePersonaSchema,
GenerateMashupSchema,
GetTaskStatusSchema,
GetTimestampedLyricsSchema,
GetLyricsDetailsSchema,
GetWavDetailsSchema,
GetVocalSeparationDetailsSchema,
GetVideoDetailsSchema,
GetCreditsSchema,
} from './schemas';
export interface ToolDefinition {
name: string;
description: string;
inputSchema: object;
}
function makeTool(name: string, description: string, schema: { safeParse: (v: unknown) => unknown }): ToolDefinition {
const jsonSchema = zodToJsonSchema(schema as any, {
name,
$refStrategy: 'none',
});
const definition = (jsonSchema as any).definitions?.[name] ?? jsonSchema;
definition.additionalProperties = false;
return {
name,
description,
inputSchema: definition,
};
}
export const TOOLS: ToolDefinition[] = [
makeTool(
'suno_generate_music',
'Generate music from a text prompt. Cost: 12 credits. Returns 2 tracks. Use customMode=true for full control (style, title, lyrics).',
GenerateMusicSchema
),
makeTool(
'suno_generate_lyrics',
'Generate lyrics from a topic. Cost: 0.4 credits.',
GenerateLyricsSchema
),
makeTool(
'suno_generate_sounds',
'Generate sound effects / ambient loops. Cost: 2.5 credits. Model V5 only.',
GenerateSoundsSchema
),
makeTool(
'suno_extend_music',
'Extend an existing track. Cost: 12 credits. Set defaultParamFlag=false to reuse source parameters.',
ExtendMusicSchema
),
makeTool(
'suno_replace_section',
'Replace a time segment of a track. Cost: 5 credits. Provide start/end seconds.',
ReplaceSectionSchema
),
makeTool(
'suno_add_vocals',
'Generate vocals over an instrumental track. Cost: 12 credits.',
AddVocalsSchema
),
makeTool(
'suno_add_instrumental',
'Generate backing music for a vocal/melody track. Cost: 12 credits.',
AddInstrumentalSchema
),
makeTool(
'suno_upload_and_cover',
'Upload an audio file URL and create a cover in a new style. Cost: 12 credits. Audio must be <= 8 minutes.',
UploadAndCoverSchema
),
makeTool(
'suno_upload_and_extend',
'Upload an audio file URL and extend it. Cost: 12 credits.',
UploadAndExtendSchema
),
makeTool(
'suno_separate_vocals',
'Separate vocals from music. Cost: 10 credits.',
SeparateVocalsSchema
),
makeTool(
'suno_separate_stems',
'Advanced stem separation (per-instrument). Cost: 20 credits.',
SeparateStemsSchema
),
makeTool(
'suno_create_video',
'Create an MP4 video from a music track. Cost: 2 credits.',
CreateVideoSchema
),
makeTool(
'suno_create_cover',
'Generate a cover image for a track. Cost: 0 credits.',
CreateCoverSchema
),
makeTool(
'suno_convert_to_wav',
'Convert a track to WAV format. Cost: 0.4 credits.',
ConvertToWavSchema
),
makeTool(
'suno_boost_style',
'Enhance a short style description into a detailed prompt. Cost: 0.4 credits.',
BoostStyleSchema
),
makeTool(
'suno_generate_persona',
'Create a reusable persona from a track. Cost: 0 credits.',
GeneratePersonaSchema
),
makeTool(
'suno_generate_mashup',
'Create a mashup from exactly 2 audio URLs. Cost: 12 credits.',
GenerateMashupSchema
),
makeTool(
'suno_get_task_status',
'Check music/extend/cover task status and get resulting tracks including prompt, title, tags and audio_url. Cost: 0 credits.',
GetTaskStatusSchema
),
makeTool(
'suno_get_timestamped_lyrics',
'Get timestamped lyrics for a track. Cost: 0.5 credits.',
GetTimestampedLyricsSchema
),
makeTool(
'suno_get_lyrics_details',
'Get details of a lyrics generation task. Cost: 0 credits.',
GetLyricsDetailsSchema
),
makeTool(
'suno_get_wav_details',
'Get details of a WAV conversion task. Cost: 0 credits.',
GetWavDetailsSchema
),
makeTool(
'suno_get_vocal_separation_details',
'Get details of a vocal/stem separation task. Cost: 0 credits.',
GetVocalSeparationDetailsSchema
),
makeTool(
'suno_get_video_details',
'Get details of a music video task. Cost: 0 credits.',
GetVideoDetailsSchema
),
makeTool(
'suno_get_credits',
'Get remaining Suno API credits. Cost: 0 credits.',
GetCreditsSchema
),
];