Initial commit: Suno MCP server with 24 tools, proxy support, and opencode skill
This commit is contained in:
@@ -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 }],
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user