Skip to content

feat: add ElevenLabs Music API integration#391

Open
sidneyswift wants to merge 2 commits intotestfrom
feature/elevenlabs-music
Open

feat: add ElevenLabs Music API integration#391
sidneyswift wants to merge 2 commits intotestfrom
feature/elevenlabs-music

Conversation

@sidneyswift
Copy link
Copy Markdown
Contributor

@sidneyswift sidneyswift commented Apr 2, 2026

Summary

  • Adds 6 API endpoints proxying ElevenLabs Music API under /api/music/
  • Adds 6 MCP tools for agent access to music generation
  • Follows existing Flamingo/content primitives patterns (auth → validate → upstream call → response)

Endpoints

Route What It Does
POST /api/music/compose Generate song from prompt or composition plan
POST /api/music/compose/detailed Generate with metadata + timestamps
POST /api/music/stream Streaming audio generation
POST /api/music/plan Create composition plan (free)
POST /api/music/video-to-music Background music from video files
POST /api/music/stem-separation Separate audio into stems

MCP Tools

compose_music, compose_music_detailed, stream_music, create_composition_plan, video_to_music, separate_stems

Files

  • 40 files changed, 1,950 insertions
  • lib/elevenlabs/ — 2 HTTP clients, shared schemas, 6 validators, 6 handlers
  • lib/mcp/tools/music/ — 6 tool registrations + index
  • 9 test files, 37 test cases — all 1,739 tests pass

Test plan

  • All 37 new ElevenLabs tests pass
  • Full test suite (1,739 tests) passes
  • Add ELEVENLABS_API_KEY to Vercel env vars before testing
  • Test compose endpoint with a simple prompt
  • Test plan endpoint (free, good smoke test)

Made with Cursor

Summary by CodeRabbit

  • New Features
    • Added music generation and composition capabilities, enabling users to create music from prompts or structured composition plans.
    • Added music streaming support for real-time audio generation.
    • Added stem separation feature for audio processing and decomposition.
    • Added video-to-music conversion functionality.
    • Integrated composition planning tools for structured music creation workflows.

Six endpoints proxying ElevenLabs Music API:
- POST /api/music/compose — generate song from prompt or composition plan
- POST /api/music/compose/detailed — generate with metadata + timestamps
- POST /api/music/stream — streaming audio generation
- POST /api/music/plan — create composition plan (free, no credits)
- POST /api/music/video-to-music — background music from video files
- POST /api/music/stem-separation — separate audio into stems

Includes:
- lib/elevenlabs/ module (2 HTTP clients, shared schemas, 6 validators, 6 handlers)
- 6 MCP tools (compose_music, compose_music_detailed, stream_music,
  create_composition_plan, video_to_music, separate_stems)
- 9 test files, 37 test cases — all passing
- ELEVENLABS_BASE_URL constant in lib/const.ts

Made-with: Cursor
@vercel
Copy link
Copy Markdown
Contributor

vercel bot commented Apr 2, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
recoup-api Ready Ready Preview Apr 2, 2026 10:01am

Request Review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 2, 2026

📝 Walkthrough

Walkthrough

Introduces ElevenLabs Music API integration with six new Next.js API routes (compose, compose/detailed, stream, plan, stem-separation, video-to-music), supporting infrastructure for upstream calls and request validation, and MCP tool registrations enabling music generation capabilities through the model context protocol.

Changes

Cohort / File(s) Summary
API Routes
app/api/music/compose/route.ts, app/api/music/compose/detailed/route.ts, app/api/music/plan/route.ts, app/api/music/stem-separation/route.ts, app/api/music/stream/route.ts, app/api/music/video-to-music/route.ts
Created six new Next.js route modules with consistent CORS preflight support, handler re-exports, and runtime configuration (dynamic, fetchCache, revalidate, maxDuration).
ElevenLabs Upstream Integration
lib/elevenlabs/callElevenLabsMusic.ts, lib/elevenlabs/callElevenLabsMusicMultipart.ts, lib/elevenlabs/buildUpstreamResponse.ts, lib/elevenlabs/handleUpstreamError.ts
Added core utilities for executing authenticated requests to ElevenLabs API, handling multipart payloads, formatting responses with CORS headers, and mapping upstream errors to standardized NextResponse objects.
Request Validation Schemas
lib/elevenlabs/schemas.ts, lib/elevenlabs/outputFormats.ts, lib/elevenlabs/compositionPlanSchema.ts, lib/elevenlabs/validateComposeBody.ts, lib/elevenlabs/validateComposeDetailedBody.ts, lib/elevenlabs/validateCreatePlanBody.ts, lib/elevenlabs/validateStreamBody.ts, lib/elevenlabs/validateStemSeparationBody.ts, lib/elevenlabs/validateVideoToMusicBody.ts
Defined Zod schemas and validators for all music endpoints with consistent error response formatting and CORS header inclusion.
Proxy Handler Factory & Endpoint Handlers
lib/elevenlabs/handleElevenLabsProxy.ts, lib/elevenlabs/composeHandler.ts, lib/elevenlabs/composeDetailedHandler.ts, lib/elevenlabs/streamHandler.ts, lib/elevenlabs/createPlanHandler.ts, lib/elevenlabs/stemSeparationHandler.ts, lib/elevenlabs/videoToMusicHandler.ts
Introduced factory createElevenLabsProxyHandler for standardized authentication/validation/error-handling flow, with seven endpoint-specific handlers; multipart handlers include file size validation and form field normalization.
MCP Tool Registration
lib/mcp/tools/music/index.ts, lib/mcp/tools/music/registerComposeMusicTool.ts, lib/mcp/tools/music/registerComposeDetailedMusicTool.ts, lib/mcp/tools/music/registerStreamMusicTool.ts, lib/mcp/tools/music/registerCreateCompositionPlanTool.ts, lib/mcp/tools/music/registerStemSeparationTool.ts, lib/mcp/tools/music/registerVideoToMusicTool.ts
Added MCP tool registrations for six music operations with consistent auth resolution, input schema binding, upstream error handling, and success result formatting.
Configuration & Integration
lib/const.ts, lib/mcp/tools/index.ts
Added ElevenLabs API base URL constant and integrated music tool registration into the main tool registration pipeline.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant NextAPI as Next.js API Route
    participant Auth as Auth System
    participant Validator as Request Validator
    participant ElevenLabs as ElevenLabs API
    participant Client2 as Client

    Client->>NextAPI: POST /api/music/compose
    NextAPI->>Auth: validateAuthContext()
    Auth-->>NextAPI: ✓ Account ID or ✗ NextResponse
    alt Auth Failed
        NextAPI-->>Client2: 401 Error Response
    else Auth Success
        NextAPI->>Validator: validateComposeBody(payload)
        Validator-->>NextAPI: ✓ Validated Body or ✗ NextResponse
        alt Validation Failed
            NextAPI-->>Client2: 400 Error Response
        else Validation Success
            NextAPI->>ElevenLabs: callElevenLabsMusic("/v1/music", body, outputFormat)
            ElevenLabs-->>NextAPI: Response (binary audio + headers)
            NextAPI->>NextAPI: handleUpstreamError(response)
            alt Upstream Error
                NextAPI-->>Client2: 502/500 Error Response
            else Upstream Success
                NextAPI->>NextAPI: buildUpstreamResponse(response, defaultContentType)
                NextAPI-->>Client2: 200 Response (audio + CORS headers)
            end
        end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

The PR spans 27 new files with mixed complexity patterns. While many follow consistent architectural templates (validation schemas, handler factories, MCP registrations), the breadth and heterogeneity of change types—spanning API routes, upstream integration, schema definitions, and tool registration—combined with logic-dense handlers for multipart processing (stemSeparationHandler, videoToMusicHandler) and the proxy factory's error-handling flow, necessitate careful verification of authentication/validation chains and upstream response handling across the ecosystem.

Possibly related PRs

Suggested reviewers

  • sweetmantech

🎵 Six new routes compose in harmony,
Validating prompts with Zod's symphony,
Upstream calls dance to ElevenLabs' tune,
CORS headers waltz beneath the moon,
MCP tools now sing in perfect key! 🎶

🚥 Pre-merge checks | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Solid & Clean Code ⚠️ Warning Pull request violates SRP with handlers having 8-9 distinct responsibilities (auth, parsing, validation, normalization, multipart building, upstream calls, error handling, response formatting). DRY violations evident in duplicated validation error responses across 6+ files and repeated auth/error handling patterns in 6 MCP tool files. Extract form parsing utilities, create shared error response builder, implement factory for MCP tool auth/error patterns, and refactor handlers to single concerns under 30 lines each focusing on orchestration only.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/elevenlabs-music

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

- Extract createElevenLabsProxyHandler factory for compose/detailed/stream
  (3 handlers → 3 config objects, shared auth+validate+proxy+error logic)
- Extract handleUpstreamError for shared upstream error handling
- Extract buildUpstreamResponse for shared response construction
- Extract musicGenerationBaseFields + promptOrPlanRefinements into schemas.ts
  (compose/detailed/stream validators now extend shared base instead of duplicating)
- Use safeParseJson instead of duplicated try/catch JSON parsing
- Net result: -55 lines, 0 duplicated handler logic

Made-with: Cursor
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 12

🧹 Nitpick comments (12)
lib/elevenlabs/callElevenLabsMusic.ts (1)

28-35: Same timeout consideration applies here.

Like the multipart variant, this fetch has no timeout. Apply AbortSignal.timeout() for consistency and resilience against upstream latency issues.

♻️ Proposed timeout addition
   return fetch(url.toString(), {
     method: "POST",
     headers: {
       "Content-Type": "application/json",
       "xi-api-key": apiKey,
     },
     body: JSON.stringify(body),
+    signal: AbortSignal.timeout(110_000),
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/elevenlabs/callElevenLabsMusic.ts` around lines 28 - 35, The POST fetch
in callElevenLabsMusic.ts lacks a timeout; wrap the request with an AbortSignal
by creating a signal (e.g., AbortSignal.timeout(TIMEOUT_MS) or a passed-in
timeout value) and pass it as the signal option to fetch in the
callElevenLabsMusic function so the request is aborted on exceed; ensure you
clear or handle the timeout appropriately (use a constant or reuse the existing
timeout value used by other ElevenLabs functions) and propagate/handle the
AbortError where needed.
app/api/music/plan/route.ts (1)

23-25: Consider adding maxDuration for consistency with other music endpoints.

Other music routes (compose, stream) set maxDuration = 120. While plan creation is likely faster, adding the same config maintains consistency and protects against unexpectedly slow upstream responses.

♻️ Optional: Add maxDuration
 export const dynamic = "force-dynamic";
 export const fetchCache = "force-no-store";
 export const revalidate = 0;
+export const maxDuration = 120;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/music/plan/route.ts` around lines 23 - 25, Add a maxDuration export
to match other music endpoints: declare export const maxDuration = 120 alongside
the existing exports (dynamic, fetchCache, revalidate) in route.ts so the plan
route uses the same maximum upstream response timeout as compose/stream and
keeps configs consistent.
lib/elevenlabs/validateStreamBody.ts (1)

23-29: Refinement errors may produce confusing missing_fields values.

When a refinement fails (e.g., "Cannot use both prompt and composition_plan"), firstError.path will be an empty array [], making missing_fields: [] potentially confusing to API consumers.

Consider conditionally including missing_fields only when the path is non-empty:

♻️ Optional: Cleaner error response for refinements
   if (!result.success) {
     const firstError = result.error.issues[0];
+    const errorBody: Record<string, unknown> = { status: "error", error: firstError.message };
+    if (firstError.path.length > 0) {
+      errorBody.missing_fields = firstError.path;
+    }
     return NextResponse.json(
-      { status: "error", missing_fields: firstError.path, error: firstError.message },
+      errorBody,
       { status: 400, headers: getCorsHeaders() },
     );
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/elevenlabs/validateStreamBody.ts` around lines 23 - 29, The current error
response always includes missing_fields using firstError.path which is an empty
array for refinement errors; update the response logic in validateStreamBody
(the block handling if (!result.success)) to only include missing_fields when
firstError.path && firstError.path.length > 0, otherwise omit missing_fields (or
return a different descriptive field such as refinement_error/message) and still
return the 400 NextResponse with getCorsHeaders(); locate the error handling
that reads result.error.issues[0] and conditionally add the missing_fields
property based on the path length.
lib/elevenlabs/callElevenLabsMusicMultipart.ts (1)

28-34: Consider adding a timeout for upstream requests.

The fetch call has no timeout configured. If ElevenLabs becomes slow or unresponsive, this could cause requests to hang indefinitely, potentially exhausting connection pools or hitting the route's maxDuration limit without graceful handling.

Consider using AbortSignal.timeout() to enforce a reasonable timeout:

♻️ Proposed timeout addition
-  return fetch(url.toString(), {
+  return fetch(url.toString(), {
     method: "POST",
     headers: {
       "xi-api-key": apiKey,
     },
     body: formData,
+    signal: AbortSignal.timeout(110_000), // slightly under maxDuration
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/elevenlabs/callElevenLabsMusicMultipart.ts` around lines 28 - 34, The
fetch to ElevenLabs in callElevenLabsMusicMultipart.ts currently has no timeout;
update the call in the function that returns fetch(url.toString(), { ... }) to
use an AbortSignal created via AbortSignal.timeout(<reasonable-ms>, /* e.g.
30000 */) and pass that signal in the fetch options (add signal: abortSignal) so
upstream requests are bounded; also ensure the surrounding caller (or this
function) catches an aborted/timeout error (AbortError) and logs/returns an
appropriate error instead of hanging.
lib/mcp/tools/music/registerComposeMusicTool.ts (1)

32-38: Resolved accountId is unused (same as compose_detailed).

The accountId is resolved but not used. If this is intentional for auth-only validation, a clarifying comment would help future maintainers understand the intent.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/mcp/tools/music/registerComposeMusicTool.ts` around lines 32 - 38, The
resolved accountId returned by resolveAccountId in registerComposeMusicTool is
never used; either use it or make the intent explicit — update
registerComposeMusicTool to either (a) use the accountId in subsequent logic
(e.g., pass it into compose-related calls) or (b) add a clear inline comment
above the resolveAccountId call explaining that the resolution is only for
authentication/validation side-effects and the id is intentionally unused; keep
resolveAccountId, getToolResultError, and compose-related identifiers intact
when making this change.
lib/mcp/tools/music/registerCreateCompositionPlanTool.ts (1)

48-49: JSON parsing could fail silently.

If the upstream returns a non-JSON response despite upstream.ok, the await upstream.json() call will throw. While the outer catch handles this, the error message "Plan creation failed" doesn't indicate a parse failure. Consider adding explicit handling or a more descriptive fallback.

💡 More explicit error handling
-        const plan = await upstream.json();
-        return getToolResultSuccess(plan);
+        try {
+          const plan = await upstream.json();
+          return getToolResultSuccess(plan);
+        } catch {
+          return getToolResultError("Failed to parse composition plan response");
+        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/mcp/tools/music/registerCreateCompositionPlanTool.ts` around lines 48 -
49, The call to upstream.json() in registerCreateCompositionPlanTool (where you
assign const plan = await upstream.json() and return getToolResultSuccess(plan))
can throw a parse error that is currently masked by a generic catch; update the
code to explicitly handle JSON parse failures by wrapping upstream.json() in its
own try/catch (or using upstream.text() fallback) and return a failed tool
result with a descriptive message (e.g., "Plan creation failed: invalid JSON
response" including any parse error.message), while still preserving the
existing upstream.ok check and using getToolResultSuccess(plan) only when
parsing succeeds.
lib/mcp/tools/music/registerComposeDetailedMusicTool.ts (1)

32-38: Resolved accountId is never used.

The tool correctly resolves and validates the account ID using resolveAccountId(), but the accountId variable is never utilized after validation. If account resolution is purely for authentication/authorization, consider adding a comment clarifying this intent. Otherwise, if the ID should be used for logging, rate limiting, or associating the generated song with the account, that logic is missing.

💡 If auth-only, add clarifying comment
       if (error) return getToolResultError(error);
       if (!accountId) return getToolResultError("Failed to resolve account ID");
+      // accountId validated for auth; not passed to upstream (ElevenLabs uses its own API key auth)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/mcp/tools/music/registerComposeDetailedMusicTool.ts` around lines 32 -
38, The resolved accountId returned by resolveAccountId(...) is validated but
never used; either (A) explicitly document that resolution is only for auth by
adding a clarifying comment after the validation (near the resolveAccountId call
and the getToolResultError checks) stating accountId is intentionally unused for
downstream operations, or (B) actually use accountId where needed—e.g., pass
accountId into the downstream song creation / persistence or
logging/rate-limiting steps (functions that handle composition creation inside
this tool) so the generated song is associated with the user and for
audit/rate-limiter hooks; update references around resolveAccountId, accountId,
and getToolResultError accordingly.
lib/elevenlabs/schemas.ts (1)

20-32: Consider type-safe refinement application.

The promptOrPlanRefinements array uses a custom pattern with check functions and message strings. While this works, consumers must manually iterate and apply these refinements using .refine(). Consider whether a helper function or a more idiomatic Zod superRefine pattern might reduce boilerplate at call sites.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/elevenlabs/schemas.ts` around lines 20 - 32, The current
promptOrPlanRefinements array requires callers to manually iterate and call
.refine(); instead export a small helper that applies these checks in a
type-safe way (or attach the logic via Zod's superRefine) so consumers just call
applyPromptOrPlanRefinement(schema) or schema.superRefine(...) once; update
promptOrPlanRefinements to expose a function named (for example)
applyPromptOrPlanRefinement that accepts a Zod object schema, uses the same
checks (ensuring the runtime shape { prompt?: string | null; composition_plan?:
unknown } is used) and adds the two validation rules via schema.superRefine or
schema.refine internally to centralize behavior and remove boilerplate at call
sites.
lib/mcp/tools/music/registerStreamMusicTool.ts (1)

32-38: Resolved accountId is unused—intentional or oversight?

The accountId is resolved via resolveAccountId() but never passed to callElevenLabsMusic. If ElevenLabs authentication is handled separately via ELEVENLABS_API_KEY, this is fine for access control gating. However, if accountId should be logged or tracked for billing/audit purposes, it's currently being discarded.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/mcp/tools/music/registerStreamMusicTool.ts` around lines 32 - 38,
resolveAccountId() returns accountId that's never used; either use it for
auditing/billing or remove the resolution. Update the code around
resolveAccountId, getToolResultError and callElevenLabsMusic: if accountId
should be tracked, pass accountId into callElevenLabsMusic (or include it in a
log/audit event before calling callElevenLabsMusic) so the resolved ID is not
discarded; if accountId is not needed for ElevenLabs calls, remove the
resolveAccountId call and related error handling to avoid unnecessary work.
lib/mcp/tools/music/registerVideoToMusicTool.ts (1)

54-59: Consider adding a timeout to video fetch operations.

Downloading videos without a timeout could cause the handler to hang indefinitely on slow or unresponsive servers. Consider using AbortController with a reasonable timeout.

💡 Suggested pattern
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 30000); // 30s timeout

try {
  const videoResponse = await fetch(url, { signal: controller.signal });
  // ...
} finally {
  clearTimeout(timeout);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/mcp/tools/music/registerVideoToMusicTool.ts` around lines 54 - 59, The
video download loop over args.video_urls in registerVideoToMusicTool can hang on
slow servers—wrap each fetch in an AbortController with a per-request timeout
(e.g., 30s), pass controller.signal to fetch, and clear the timeout in a finally
block; on abort or non-ok responses, return a descriptive getToolResultError
mentioning the URL and the error/timeout so the handler fails fast and doesn’t
hang indefinitely.
lib/mcp/tools/music/registerStemSeparationTool.ts (1)

75-78: Success response lacks actionable retrieval info.

The success message mentions the ZIP is "available via POST /api/music/stem-separation" but this is the same endpoint that initiates the separation—not a retrieval endpoint. If there's a separate way to retrieve the stems (e.g., via a song ID or download URL), consider including that in the response.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/mcp/tools/music/registerStemSeparationTool.ts` around lines 75 - 78, The
success response returned by getToolResultSuccess (in the
registerStemSeparationTool handler) is misleading — it points back to the
initiating POST endpoint instead of giving a way to retrieve the ZIP; update the
returned payload to include actionable retrieval details (e.g., add a
download_url or retrieval_id and the correct retrieval endpoint or song_id)
alongside stem_variation_id so callers can directly fetch the archive; modify
the object passed to getToolResultSuccess (the return in
registerStemSeparationTool) to include these new fields and ensure the values
are generated/filled by the stem separation flow (e.g., a presigned URL,
download token, or song ID).
lib/elevenlabs/videoToMusicHandler.ts (1)

21-105: Consider extracting shared logic to reduce function length and improve DRY.

This handler is 84 lines, exceeding the 50-line guideline. The text field extraction logic (lines 57-68) is nearly identical to stemSeparationHandler.ts (lines 43-52). Consider extracting:

  1. A shared extractTextFields(formData, excludeKey, booleanFields) utility
  2. Potentially a shared multipart handler factory similar to createElevenLabsProxyHandler

This would improve maintainability and reduce duplication across the multipart handlers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/elevenlabs/videoToMusicHandler.ts` around lines 21 - 105,
videoToMusicHandler is too long and duplicates text-field parsing from
stemSeparationHandler; extract a reusable helper (e.g.,
extractTextFields(formData: FormData, excludeKey: string, booleanFields:
string[])) and use it in videoToMusicHandler before calling
validateVideoToMusicBody to populate textFields, handling tags -> string[] and
boolean conversion (sign_with_c2pa) inside that helper; optionally factor
repeated multipart setup into a createElevenLabsProxyHandler-style factory to
build upstreamForm and callElevenLabsMusicMultipart, replacing the manual loop
that appends videos/tags/description/sign_with_c2pa so videoToMusicHandler
becomes short and delegates parsing to extractTextFields and multipart
construction to the factory.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/api/music/compose/route.ts`:
- Around line 14-20: getCorsHeaders() currently does not expose custom response
headers, so browser clients cannot read the song-id or content-disposition set
by buildUpstreamResponse(); update getCorsHeaders() to include
"Access-Control-Expose-Headers" with the values "song-id, content-disposition"
so composeHandler (exported as POST), stemSeparationHandler and any endpoints
that use buildUpstreamResponse() can surface those headers to browser clients.

In `@lib/elevenlabs/buildUpstreamResponse.ts`:
- Line 31: The passthrough response currently hardcodes status 200 in the
Response construction inside buildUpstreamResponse, which hides the real
upstream status; change the Response creation to use upstream.status (i.e.,
replace the literal 200 with upstream.status) so the function returns the
original upstream status code (and continue to pass the constructed headers and
body as before).
- Around line 20-29: The current CORS headers returned by getCorsHeaders() don't
expose custom response headers so browser clients can't read headers like
"song-id" or "content-disposition"; update the getCorsHeaders() function
(lib/networking/getCorsHeaders.ts) to include an "Access-Control-Expose-Headers"
entry listing at least "song-id" and "content-disposition" (and any other
forwarded/custom headers you expect), so when buildUpstreamResponse.ts spreads
getCorsHeaders() into the response headers those headers become readable in
browsers while keeping the existing forwardHeaders handling intact.

In `@lib/elevenlabs/compositionPlanSchema.ts`:
- Around line 7-16: The sections array schema currently allows empty arrays;
update the z.array(...) used in the sections property of the composition plan
schema to enforce at least one element by appending .min(1) to that array
validator (i.e., modify the sections: z.array(...) declaration that wraps the
z.object({...}) so it becomes z.array(...).min(1)), ensuring the schema matches
ElevenLabs API requirement for at least one section.

In `@lib/elevenlabs/stemSeparationHandler.ts`:
- Around line 35-41: The code unsafely casts incomingForm.get("file") to File |
null; instead, check the actual type returned by incomingForm.get before
treating it as a File: retrieve the value into a variable (e.g., fileEntry =
incomingForm.get("file")), verify it's not null and is an instance of File
(using instanceof File), and if the check fails return the 400 JSON error;
update any subsequent uses to rely on the validated File variable (file) so
string values won't silently break downstream logic in stemSeparationHandler.ts.

In `@lib/elevenlabs/validateStemSeparationBody.ts`:
- Line 1: The file fails Prettier formatting; run Prettier to fix formatting
inconsistencies (e.g., run prettier --write on
lib/elevenlabs/validateStemSeparationBody.ts) and commit the reformatted file so
the import line (import { NextResponse } from "next/server";) and any other
lines conform to the project's Prettier rules; ensure no other lint/format
errors remain in validateStemSeparationBody.ts before pushing.

In `@lib/elevenlabs/videoToMusicHandler.ts`:
- Line 1: Prettier flagged formatting issues in
lib/elevenlabs/videoToMusicHandler.ts; run a formatting pass (e.g., run
`prettier --write` on that file) or apply the project's Prettier rules to fix
spacing/line breaks so the import and other declarations conform to the
project's style; after formatting, stage the updated file and re-run CI to
ensure the Prettier check passes.
- Line 35: Replace the unsafe cast of incomingForm.getAll("videos") to File[] by
explicitly filtering and validating the returned values: call
incomingForm.getAll("videos"), iterate the array and keep only entries that are
instances of File (use instanceof File), validate each File's size and type as
required, and then assign the filtered list to the videos variable; also handle
the case where no valid files remain (e.g., return an error). This change
affects the code around incomingForm.getAll("videos") and the videos variable
usage in videoToMusicHandler.

In `@lib/mcp/tools/music/registerComposeMusicTool.ts`:
- Line 1: The file has Prettier formatting issues around the import line (import
{ McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"); run the project's
formatter on this file (e.g., run prettier --write
lib/mcp/tools/music/registerComposeMusicTool.ts) or reformat the import and
entire file to match the project's Prettier config so the pipeline passes;
ensure the import statement and surrounding whitespace/line endings conform to
Prettier rules.
- Around line 11-16: Update the JSDoc for registerComposeMusicTool to accurately
describe behavior: state that it generates a song via ElevenLabs (or the
ElevenLabs TTS service), returns the song_id (or result from ElevenLabs) and
does not save audio to Supabase storage; remove or replace the incorrect "saves
audio to Supabase storage" clause and mention that the function returns a
song_id for downstream handling (referencing registerComposeMusicTool and the
compose_music tool name).

In `@lib/mcp/tools/music/registerStemSeparationTool.ts`:
- Around line 51-56: The audio download in registerStemSeparationTool.ts uses
args.audio_url directly (audioResponse = await fetch(args.audio_url)) and needs
the same SSRF protections as registerVideoToMusicTool.ts; before calling fetch
or constructing filename from args.audio_url, validate the URL with a shared URL
validation utility (e.g., isSafeUrl or validateExternalUrl) that rejects
non-HTTP(S), private/internal IPs, localhost, link-local addresses, and cloud
metadata endpoints, and return getToolResultError on validation failure; add or
import the shared validator and call it at the start of the download flow (prior
to fetch, filename extraction), ensuring all error messages include the
offending URL for diagnostics.

In `@lib/mcp/tools/music/registerVideoToMusicTool.ts`:
- Around line 54-62: The loop that downloads user-supplied URLs (iterate over
args.video_urls) currently uses fetch without scheme/IP validation or timeout;
update the code around the for (const url of args.video_urls) block to (1)
enforce HTTPS-only URLs (reject any URL not starting with https: before
fetching), (2) perform a DNS/IP resolution or parse the hostname and block
private/internal ranges (169.254.0.0/16, 127.0.0.0/8, 10.0.0.0/8,
192.168.0.0/16, etc.) before downloading, and (3) add a fetch timeout (use
AbortController with ~30s) so the fetch(url) call cannot hang; return
getToolResultError on validation failures and proceed to blob/formData.append
only after the checks pass.

---

Nitpick comments:
In `@app/api/music/plan/route.ts`:
- Around line 23-25: Add a maxDuration export to match other music endpoints:
declare export const maxDuration = 120 alongside the existing exports (dynamic,
fetchCache, revalidate) in route.ts so the plan route uses the same maximum
upstream response timeout as compose/stream and keeps configs consistent.

In `@lib/elevenlabs/callElevenLabsMusic.ts`:
- Around line 28-35: The POST fetch in callElevenLabsMusic.ts lacks a timeout;
wrap the request with an AbortSignal by creating a signal (e.g.,
AbortSignal.timeout(TIMEOUT_MS) or a passed-in timeout value) and pass it as the
signal option to fetch in the callElevenLabsMusic function so the request is
aborted on exceed; ensure you clear or handle the timeout appropriately (use a
constant or reuse the existing timeout value used by other ElevenLabs functions)
and propagate/handle the AbortError where needed.

In `@lib/elevenlabs/callElevenLabsMusicMultipart.ts`:
- Around line 28-34: The fetch to ElevenLabs in callElevenLabsMusicMultipart.ts
currently has no timeout; update the call in the function that returns
fetch(url.toString(), { ... }) to use an AbortSignal created via
AbortSignal.timeout(<reasonable-ms>, /* e.g. 30000 */) and pass that signal in
the fetch options (add signal: abortSignal) so upstream requests are bounded;
also ensure the surrounding caller (or this function) catches an aborted/timeout
error (AbortError) and logs/returns an appropriate error instead of hanging.

In `@lib/elevenlabs/schemas.ts`:
- Around line 20-32: The current promptOrPlanRefinements array requires callers
to manually iterate and call .refine(); instead export a small helper that
applies these checks in a type-safe way (or attach the logic via Zod's
superRefine) so consumers just call applyPromptOrPlanRefinement(schema) or
schema.superRefine(...) once; update promptOrPlanRefinements to expose a
function named (for example) applyPromptOrPlanRefinement that accepts a Zod
object schema, uses the same checks (ensuring the runtime shape { prompt?:
string | null; composition_plan?: unknown } is used) and adds the two validation
rules via schema.superRefine or schema.refine internally to centralize behavior
and remove boilerplate at call sites.

In `@lib/elevenlabs/validateStreamBody.ts`:
- Around line 23-29: The current error response always includes missing_fields
using firstError.path which is an empty array for refinement errors; update the
response logic in validateStreamBody (the block handling if (!result.success))
to only include missing_fields when firstError.path && firstError.path.length >
0, otherwise omit missing_fields (or return a different descriptive field such
as refinement_error/message) and still return the 400 NextResponse with
getCorsHeaders(); locate the error handling that reads result.error.issues[0]
and conditionally add the missing_fields property based on the path length.

In `@lib/elevenlabs/videoToMusicHandler.ts`:
- Around line 21-105: videoToMusicHandler is too long and duplicates text-field
parsing from stemSeparationHandler; extract a reusable helper (e.g.,
extractTextFields(formData: FormData, excludeKey: string, booleanFields:
string[])) and use it in videoToMusicHandler before calling
validateVideoToMusicBody to populate textFields, handling tags -> string[] and
boolean conversion (sign_with_c2pa) inside that helper; optionally factor
repeated multipart setup into a createElevenLabsProxyHandler-style factory to
build upstreamForm and callElevenLabsMusicMultipart, replacing the manual loop
that appends videos/tags/description/sign_with_c2pa so videoToMusicHandler
becomes short and delegates parsing to extractTextFields and multipart
construction to the factory.

In `@lib/mcp/tools/music/registerComposeDetailedMusicTool.ts`:
- Around line 32-38: The resolved accountId returned by resolveAccountId(...) is
validated but never used; either (A) explicitly document that resolution is only
for auth by adding a clarifying comment after the validation (near the
resolveAccountId call and the getToolResultError checks) stating accountId is
intentionally unused for downstream operations, or (B) actually use accountId
where needed—e.g., pass accountId into the downstream song creation /
persistence or logging/rate-limiting steps (functions that handle composition
creation inside this tool) so the generated song is associated with the user and
for audit/rate-limiter hooks; update references around resolveAccountId,
accountId, and getToolResultError accordingly.

In `@lib/mcp/tools/music/registerComposeMusicTool.ts`:
- Around line 32-38: The resolved accountId returned by resolveAccountId in
registerComposeMusicTool is never used; either use it or make the intent
explicit — update registerComposeMusicTool to either (a) use the accountId in
subsequent logic (e.g., pass it into compose-related calls) or (b) add a clear
inline comment above the resolveAccountId call explaining that the resolution is
only for authentication/validation side-effects and the id is intentionally
unused; keep resolveAccountId, getToolResultError, and compose-related
identifiers intact when making this change.

In `@lib/mcp/tools/music/registerCreateCompositionPlanTool.ts`:
- Around line 48-49: The call to upstream.json() in
registerCreateCompositionPlanTool (where you assign const plan = await
upstream.json() and return getToolResultSuccess(plan)) can throw a parse error
that is currently masked by a generic catch; update the code to explicitly
handle JSON parse failures by wrapping upstream.json() in its own try/catch (or
using upstream.text() fallback) and return a failed tool result with a
descriptive message (e.g., "Plan creation failed: invalid JSON response"
including any parse error.message), while still preserving the existing
upstream.ok check and using getToolResultSuccess(plan) only when parsing
succeeds.

In `@lib/mcp/tools/music/registerStemSeparationTool.ts`:
- Around line 75-78: The success response returned by getToolResultSuccess (in
the registerStemSeparationTool handler) is misleading — it points back to the
initiating POST endpoint instead of giving a way to retrieve the ZIP; update the
returned payload to include actionable retrieval details (e.g., add a
download_url or retrieval_id and the correct retrieval endpoint or song_id)
alongside stem_variation_id so callers can directly fetch the archive; modify
the object passed to getToolResultSuccess (the return in
registerStemSeparationTool) to include these new fields and ensure the values
are generated/filled by the stem separation flow (e.g., a presigned URL,
download token, or song ID).

In `@lib/mcp/tools/music/registerStreamMusicTool.ts`:
- Around line 32-38: resolveAccountId() returns accountId that's never used;
either use it for auditing/billing or remove the resolution. Update the code
around resolveAccountId, getToolResultError and callElevenLabsMusic: if
accountId should be tracked, pass accountId into callElevenLabsMusic (or include
it in a log/audit event before calling callElevenLabsMusic) so the resolved ID
is not discarded; if accountId is not needed for ElevenLabs calls, remove the
resolveAccountId call and related error handling to avoid unnecessary work.

In `@lib/mcp/tools/music/registerVideoToMusicTool.ts`:
- Around line 54-59: The video download loop over args.video_urls in
registerVideoToMusicTool can hang on slow servers—wrap each fetch in an
AbortController with a per-request timeout (e.g., 30s), pass controller.signal
to fetch, and clear the timeout in a finally block; on abort or non-ok
responses, return a descriptive getToolResultError mentioning the URL and the
error/timeout so the handler fails fast and doesn’t hang indefinitely.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 5b6db4f3-8d8c-4f68-b9d6-01ca7b5943dc

📥 Commits

Reviewing files that changed from the base of the PR and between 237567d and 9515175.

⛔ Files ignored due to path filters (9)
  • lib/elevenlabs/__tests__/callElevenLabsMusic.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/elevenlabs/__tests__/callElevenLabsMusicMultipart.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/elevenlabs/__tests__/composeHandler.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/elevenlabs/__tests__/createPlanHandler.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/elevenlabs/__tests__/validateComposeBody.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/elevenlabs/__tests__/validateCreatePlanBody.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/elevenlabs/__tests__/validateStemSeparationBody.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/elevenlabs/__tests__/validateStreamBody.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/elevenlabs/__tests__/validateVideoToMusicBody.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
📒 Files selected for processing (35)
  • app/api/music/compose/detailed/route.ts
  • app/api/music/compose/route.ts
  • app/api/music/plan/route.ts
  • app/api/music/stem-separation/route.ts
  • app/api/music/stream/route.ts
  • app/api/music/video-to-music/route.ts
  • lib/const.ts
  • lib/elevenlabs/buildUpstreamResponse.ts
  • lib/elevenlabs/callElevenLabsMusic.ts
  • lib/elevenlabs/callElevenLabsMusicMultipart.ts
  • lib/elevenlabs/composeDetailedHandler.ts
  • lib/elevenlabs/composeHandler.ts
  • lib/elevenlabs/compositionPlanSchema.ts
  • lib/elevenlabs/createPlanHandler.ts
  • lib/elevenlabs/handleElevenLabsProxy.ts
  • lib/elevenlabs/handleUpstreamError.ts
  • lib/elevenlabs/outputFormats.ts
  • lib/elevenlabs/schemas.ts
  • lib/elevenlabs/stemSeparationHandler.ts
  • lib/elevenlabs/streamHandler.ts
  • lib/elevenlabs/validateComposeBody.ts
  • lib/elevenlabs/validateComposeDetailedBody.ts
  • lib/elevenlabs/validateCreatePlanBody.ts
  • lib/elevenlabs/validateStemSeparationBody.ts
  • lib/elevenlabs/validateStreamBody.ts
  • lib/elevenlabs/validateVideoToMusicBody.ts
  • lib/elevenlabs/videoToMusicHandler.ts
  • lib/mcp/tools/index.ts
  • lib/mcp/tools/music/index.ts
  • lib/mcp/tools/music/registerComposeDetailedMusicTool.ts
  • lib/mcp/tools/music/registerComposeMusicTool.ts
  • lib/mcp/tools/music/registerCreateCompositionPlanTool.ts
  • lib/mcp/tools/music/registerStemSeparationTool.ts
  • lib/mcp/tools/music/registerStreamMusicTool.ts
  • lib/mcp/tools/music/registerVideoToMusicTool.ts

Comment on lines +14 to +20
/**
* POST /api/music/compose
*
* Generate a song from a text prompt or composition plan using ElevenLabs Music AI.
* Returns binary audio. The song-id is returned in the response headers.
*/
export { composeHandler as POST };
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify getCorsHeaders doesn't expose custom headers
cat lib/networking/getCorsHeaders.ts

# Check which handlers set custom headers like song-id
rg -n "song-id|content-disposition" lib/elevenlabs/

Repository: recoupable/api

Length of output: 1263


🏁 Script executed:

#!/bin/bash
# Get full buildUpstreamResponse implementation
cat lib/elevenlabs/buildUpstreamResponse.ts

# Check if any route handler sets Access-Control-Expose-Headers
rg -n "Access-Control-Expose-Headers" .

# Check the composeHandler to see how it uses buildUpstreamResponse
cat lib/elevenlabs/composeHandler.ts

Repository: recoupable/api

Length of output: 1676


🏁 Script executed:

#!/bin/bash
# Get full route file context
cat app/api/music/compose/route.ts

# Check test setup to understand CORS testing
head -80 lib/elevenlabs/__tests__/composeHandler.test.ts

Repository: recoupable/api

Length of output: 3246


🏁 Script executed:

#!/bin/bash
# Check if there are other places where Access-Control-Expose-Headers is set or handled
rg -n "Expose-Headers|forwardHeaders" --type ts --type tsx

# Check all uses of buildUpstreamResponse to see the pattern
rg -n "buildUpstreamResponse" --type ts --type tsx -B 2 -A 2

Repository: recoupable/api

Length of output: 113


🏁 Script executed:

#!/bin/bash
# Check if there are other places where Access-Control-Expose-Headers is set or handled
rg -n "Expose-Headers|forwardHeaders"

# Check all uses of buildUpstreamResponse to see the pattern
rg -n "buildUpstreamResponse" -B 2 -A 2

Repository: recoupable/api

Length of output: 3257


Browser clients cannot read response headers without Access-Control-Expose-Headers in CORS response.

The endpoint correctly sets song-id in response headers, but getCorsHeaders() doesn't include Access-Control-Expose-Headers. This blocks browser clients from reading the header—the browser's CORS policy will strip it. The same issue affects stemSeparationHandler with content-disposition.

Update getCorsHeaders() to expose custom headers:

"Access-Control-Expose-Headers": "song-id, content-disposition"

This affects all music endpoints using buildUpstreamResponse() (compose, video-to-music, stem-separation).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/music/compose/route.ts` around lines 14 - 20, getCorsHeaders()
currently does not expose custom response headers, so browser clients cannot
read the song-id or content-disposition set by buildUpstreamResponse(); update
getCorsHeaders() to include "Access-Control-Expose-Headers" with the values
"song-id, content-disposition" so composeHandler (exported as POST),
stemSeparationHandler and any endpoints that use buildUpstreamResponse() can
surface those headers to browser clients.

Comment on lines +20 to +29
const headers: Record<string, string> = {
...getCorsHeaders(),
"Content-Type": contentType,
};
if (songId) headers["song-id"] = songId;

for (const name of forwardHeaders) {
const value = upstream.headers.get(name);
if (value) headers[name] = value;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== getCorsHeaders implementation =="
fd -i 'getCorsHeaders.ts' | while read -r f; do
  echo "--- $f ---"
  sed -n '1,220p' "$f"
done

echo
echo "== References to expose headers and forwarded music headers =="
rg -n "Access-Control-Expose-Headers|song-id|content-disposition|forwardHeaders"

Repository: recoupable/api

Length of output: 3016


🏁 Script executed:

rg -n "Access-Control-Expose-Headers" lib/

Repository: recoupable/api

Length of output: 40


Add Access-Control-Expose-Headers to expose response headers to browser clients.

Response headers like song-id and content-disposition are being set but cannot be read by browser clients because they are not exposed via CORS. Update getCorsHeaders() in lib/networking/getCorsHeaders.ts to include Access-Control-Expose-Headers with the necessary header names (e.g., song-id, content-disposition). Without this, tests and client code expecting to read these headers will fail in browser environments.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/elevenlabs/buildUpstreamResponse.ts` around lines 20 - 29, The current
CORS headers returned by getCorsHeaders() don't expose custom response headers
so browser clients can't read headers like "song-id" or "content-disposition";
update the getCorsHeaders() function (lib/networking/getCorsHeaders.ts) to
include an "Access-Control-Expose-Headers" entry listing at least "song-id" and
"content-disposition" (and any other forwarded/custom headers you expect), so
when buildUpstreamResponse.ts spreads getCorsHeaders() into the response headers
those headers become readable in browsers while keeping the existing
forwardHeaders handling intact.

if (value) headers[name] = value;
}

return new Response(upstream.body, { status: 200, headers });
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Preserve upstream status codes on passthrough responses.

Line 31 always returns 200, which can mask legitimate upstream success statuses (for example partial content). Forward upstream.status instead.

Proposed fix
-  return new Response(upstream.body, { status: 200, headers });
+  return new Response(upstream.body, { status: upstream.status, headers });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return new Response(upstream.body, { status: 200, headers });
return new Response(upstream.body, { status: upstream.status, headers });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/elevenlabs/buildUpstreamResponse.ts` at line 31, The passthrough response
currently hardcodes status 200 in the Response construction inside
buildUpstreamResponse, which hides the real upstream status; change the Response
creation to use upstream.status (i.e., replace the literal 200 with
upstream.status) so the function returns the original upstream status code (and
continue to pass the constructed headers and body as before).

Comment on lines +7 to +16
sections: z.array(
z.object({
title: z.string(),
lyrics: z.string().nullable().optional(),
duration_ms: z.number().int().min(3000).max(600000).optional(),
positive_styles: z.array(z.string()).optional(),
negative_styles: z.array(z.string()).optional(),
audio_url: z.string().url().nullable().optional(),
}),
),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Does ElevenLabs Music API composition plan require at least one section?

💡 Result:

Yes, the ElevenLabs Music API composition plan requires at least one section. The composition plan is defined as a MusicPrompt object, which mandates the "sections" field as a required array of SongSection objects. Official API schema explicitly lists required fields: positive_global_styles, negative_global_styles, and sections. All examples include at least one section, and documentation describes sections as essential for defining song structure with up to 30 sections, each having a minimum duration of 3000ms. Total song duration constraints (3s to 10min) imply at least one section is necessary.

Citations:


Add .min(1) constraint to sections array to match ElevenLabs API requirements.

The ElevenLabs Music API explicitly requires at least one section in the composition plan. The current schema allows an empty sections array, which would pass validation but fail at the API. Add the minimum length constraint:

Enforce minimum one section
  sections: z.array(
    z.object({
      title: z.string(),
      lyrics: z.string().nullable().optional(),
      duration_ms: z.number().int().min(3000).max(600000).optional(),
      positive_styles: z.array(z.string()).optional(),
      negative_styles: z.array(z.string()).optional(),
      audio_url: z.string().url().nullable().optional(),
    }),
-  ),
+  ).min(1),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
sections: z.array(
z.object({
title: z.string(),
lyrics: z.string().nullable().optional(),
duration_ms: z.number().int().min(3000).max(600000).optional(),
positive_styles: z.array(z.string()).optional(),
negative_styles: z.array(z.string()).optional(),
audio_url: z.string().url().nullable().optional(),
}),
),
sections: z.array(
z.object({
title: z.string(),
lyrics: z.string().nullable().optional(),
duration_ms: z.number().int().min(3000).max(600000).optional(),
positive_styles: z.array(z.string()).optional(),
negative_styles: z.array(z.string()).optional(),
audio_url: z.string().url().nullable().optional(),
}),
).min(1),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/elevenlabs/compositionPlanSchema.ts` around lines 7 - 16, The sections
array schema currently allows empty arrays; update the z.array(...) used in the
sections property of the composition plan schema to enforce at least one element
by appending .min(1) to that array validator (i.e., modify the sections:
z.array(...) declaration that wraps the z.object({...}) so it becomes
z.array(...).min(1)), ensuring the schema matches ElevenLabs API requirement for
at least one section.

Comment on lines +35 to +41
const file = incomingForm.get("file") as File | null;
if (!file) {
return NextResponse.json(
{ status: "error", error: "An audio file is required in the 'file' field" },
{ status: 400, headers: getCorsHeaders() },
);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Unsafe type assertion on form data entry.

FormData.get() returns FormDataEntryValue | null, which is File | string | null. Casting directly to File | null is unsafe—a string value would pass the null check but fail silently downstream.

🛡️ Proposed fix to validate file instance
-  const file = incomingForm.get("file") as File | null;
-  if (!file) {
+  const fileEntry = incomingForm.get("file");
+  if (!fileEntry || !(fileEntry instanceof File)) {
     return NextResponse.json(
       { status: "error", error: "An audio file is required in the 'file' field" },
       { status: 400, headers: getCorsHeaders() },
     );
   }
+  const file = fileEntry;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const file = incomingForm.get("file") as File | null;
if (!file) {
return NextResponse.json(
{ status: "error", error: "An audio file is required in the 'file' field" },
{ status: 400, headers: getCorsHeaders() },
);
}
const fileEntry = incomingForm.get("file");
if (!fileEntry || !(fileEntry instanceof File)) {
return NextResponse.json(
{ status: "error", error: "An audio file is required in the 'file' field" },
{ status: 400, headers: getCorsHeaders() },
);
}
const file = fileEntry;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/elevenlabs/stemSeparationHandler.ts` around lines 35 - 41, The code
unsafely casts incomingForm.get("file") to File | null; instead, check the
actual type returned by incomingForm.get before treating it as a File: retrieve
the value into a variable (e.g., fileEntry = incomingForm.get("file")), verify
it's not null and is an instance of File (using instanceof File), and if the
check fails return the 400 JSON error; update any subsequent uses to rely on the
validated File variable (file) so string values won't silently break downstream
logic in stemSeparationHandler.ts.

);
}

const videos = incomingForm.getAll("videos") as File[];
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Unsafe type assertion on getAll("videos") result.

FormData.getAll() returns (File | string)[], so casting directly to File[] is unsafe. A malicious or misconfigured client could send string values under the videos field, bypassing your size checks.

🛡️ Proposed fix to filter and validate file entries
-  const videos = incomingForm.getAll("videos") as File[];
-  if (videos.length === 0) {
+  const videoEntries = incomingForm.getAll("videos");
+  const videos = videoEntries.filter((v): v is File => v instanceof File);
+  if (videos.length === 0) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const videos = incomingForm.getAll("videos") as File[];
const videoEntries = incomingForm.getAll("videos");
const videos = videoEntries.filter((v): v is File => v instanceof File);
if (videos.length === 0) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/elevenlabs/videoToMusicHandler.ts` at line 35, Replace the unsafe cast of
incomingForm.getAll("videos") to File[] by explicitly filtering and validating
the returned values: call incomingForm.getAll("videos"), iterate the array and
keep only entries that are instances of File (use instanceof File), validate
each File's size and type as required, and then assign the filtered list to the
videos variable; also handle the case where no valid files remain (e.g., return
an error). This change affects the code around incomingForm.getAll("videos") and
the videos variable usage in videoToMusicHandler.

@@ -0,0 +1,67 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix Prettier formatting.

Pipeline reports a formatting issue. Run prettier --write on this file.

🧰 Tools
🪛 GitHub Actions: Format Check

[warning] 1-1: Prettier --check reported a formatting/style issue in this file. Code style issues found; run Prettier with --write to fix.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/mcp/tools/music/registerComposeMusicTool.ts` at line 1, The file has
Prettier formatting issues around the import line (import { McpServer } from
"@modelcontextprotocol/sdk/server/mcp.js"); run the project's formatter on this
file (e.g., run prettier --write
lib/mcp/tools/music/registerComposeMusicTool.ts) or reformat the import and
entire file to match the project's Prettier config so the pipeline passes;
ensure the import statement and surrounding whitespace/line endings conform to
Prettier rules.

Comment on lines +11 to +16
/**
* Registers the compose_music MCP tool.
* Generates a song, saves audio to Supabase storage, and returns the URL.
*
* @param server - The MCP server instance.
*/
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

JSDoc description is inaccurate.

The JSDoc states "saves audio to Supabase storage," but the implementation only calls ElevenLabs and returns the song_id. The audio is not stored in Supabase. Update the documentation to match actual behavior.

📝 Suggested fix
 /**
  * Registers the compose_music MCP tool.
- * Generates a song, saves audio to Supabase storage, and returns the URL.
+ * Generates a song via ElevenLabs Music AI and returns the song ID.
  *
  * `@param` server - The MCP server instance.
  */
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/**
* Registers the compose_music MCP tool.
* Generates a song, saves audio to Supabase storage, and returns the URL.
*
* @param server - The MCP server instance.
*/
/**
* Registers the compose_music MCP tool.
* Generates a song via ElevenLabs Music AI and returns the song ID.
*
* `@param` server - The MCP server instance.
*/
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/mcp/tools/music/registerComposeMusicTool.ts` around lines 11 - 16, Update
the JSDoc for registerComposeMusicTool to accurately describe behavior: state
that it generates a song via ElevenLabs (or the ElevenLabs TTS service), returns
the song_id (or result from ElevenLabs) and does not save audio to Supabase
storage; remove or replace the incorrect "saves audio to Supabase storage"
clause and mention that the function returns a song_id for downstream handling
(referencing registerComposeMusicTool and the compose_music tool name).

Comment on lines +51 to +56
const audioResponse = await fetch(args.audio_url);
if (!audioResponse.ok) {
return getToolResultError(`Failed to download audio from ${args.audio_url}`);
}
const blob = await audioResponse.blob();
const filename = args.audio_url.split("/").pop() ?? "audio.mp3";
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Same SSRF risk applies here.

The audio URL download has the same SSRF vulnerability as registerVideoToMusicTool.ts. User-provided URLs should be validated to prevent access to internal networks and cloud metadata endpoints. Consider implementing a shared URL validation utility that both tools can use.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/mcp/tools/music/registerStemSeparationTool.ts` around lines 51 - 56, The
audio download in registerStemSeparationTool.ts uses args.audio_url directly
(audioResponse = await fetch(args.audio_url)) and needs the same SSRF
protections as registerVideoToMusicTool.ts; before calling fetch or constructing
filename from args.audio_url, validate the URL with a shared URL validation
utility (e.g., isSafeUrl or validateExternalUrl) that rejects non-HTTP(S),
private/internal IPs, localhost, link-local addresses, and cloud metadata
endpoints, and return getToolResultError on validation failure; add or import
the shared validator and call it at the start of the download flow (prior to
fetch, filename extraction), ensuring all error messages include the offending
URL for diagnostics.

Comment on lines +54 to +62
for (const url of args.video_urls) {
const videoResponse = await fetch(url);
if (!videoResponse.ok) {
return getToolResultError(`Failed to download video from ${url}`);
}
const blob = await videoResponse.blob();
const filename = url.split("/").pop() ?? "video.mp4";
formData.append("videos", blob, filename);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if there's existing URL validation infrastructure in the codebase
rg -n "ssrf|validateUrl|isPublicUrl|allowedHost" --type=ts -C2

Repository: recoupable/api

Length of output: 40


🏁 Script executed:

cat -n lib/mcp/tools/music/registerVideoToMusicTool.ts

Repository: recoupable/api

Length of output: 4377


🏁 Script executed:

# Broader search for URL validation patterns
rg -n "url|fetch|validation|validate" --type=ts -i lib/mcp/ | head -40

Repository: recoupable/api

Length of output: 5080


🏁 Script executed:

# Search for URL scheme validation or similar patterns
rg -n "https?|http|scheme|protocol|private|internal" --type=ts -C1 lib/ | grep -E "(validate|check|allow|block)" | head -20

Repository: recoupable/api

Length of output: 1996


🏁 Script executed:

# Check if there are any utility files for validation
fd -t f "validat|sanitiz|url" --type f lib/

Repository: recoupable/api

Length of output: 7974


SSRF vulnerability: URLs lack scheme and IP range validation.

The tool downloads videos from user-provided URLs validated only by Zod's .url() method, which checks URL format syntax but does NOT restrict schemes or IP ranges. This allows:

  • Internal metadata endpoints: http://169.254.169.254/ (AWS/cloud metadata)
  • Private network hosts: http://localhost:8080/, http://192.168.x.x/
  • Data exfiltration via DNS or response timing

Additionally, missing timeout on fetch (line 55) risks hanging requests and DoS attacks.

Recommendations:

  1. Restrict to https:// scheme only
  2. Block private/internal IP ranges (169.254.x.x, 10.x.x.x, 192.168.x.x, 127.x.x.x)
  3. Add timeout to fetch calls (e.g., 30 seconds)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/mcp/tools/music/registerVideoToMusicTool.ts` around lines 54 - 62, The
loop that downloads user-supplied URLs (iterate over args.video_urls) currently
uses fetch without scheme/IP validation or timeout; update the code around the
for (const url of args.video_urls) block to (1) enforce HTTPS-only URLs (reject
any URL not starting with https: before fetching), (2) perform a DNS/IP
resolution or parse the hostname and block private/internal ranges
(169.254.0.0/16, 127.0.0.0/8, 10.0.0.0/8, 192.168.0.0/16, etc.) before
downloading, and (3) add a fetch timeout (use AbortController with ~30s) so the
fetch(url) call cannot hang; return getToolResultError on validation failures
and proceed to blob/formData.append only after the checks pass.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant