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`);
}
}