> ## Documentation Index
> Fetch the complete documentation index at: https://cue.vibeset.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# MCP tools reference

> Every Cue MCP tool, with parameters, examples, and what the first call actually returns.

Cue's [MCP server](/docs/mcp-server) exposes eight tools. Each authenticates with your API key and bills
exactly like the REST endpoint it wraps. Cold calls return a `job_id` you poll with
[`check_job`](#check_job).

Five are the workflows. Three are plumbing.

| Workflow                  | Tool                                        | Wraps                 |
| ------------------------- | ------------------------------------------- | --------------------- |
| [Match](/docs/match)           | [`match_music`](#match_music)               | `POST /v1/match`      |
| [Underscore](/docs/underscore) | [`underscore`](#underscore)                 | `POST /v1/underscore` |
| [Sync](/docs/sync)             | [`get_sync_points`](#get_sync_points)       | `POST /v1/align`      |
| [Loop](/docs/loop)             | [`loop_music`](#loop_music)                 | `POST /v1/loop`       |
| [Beats](/docs/beats)           | [`map_beats`](#map_beats)                   | `POST /v1/beats`      |
| —                         | [`ingest_video`](#ingest_video)             | `POST /v1/videos`     |
| —                         | [`check_video_status`](#check_video_status) | `GET /v1/videos/{id}` |
| —                         | [`check_job`](#check_job)                   | The job endpoints     |

The five workflow tools take an optional `idempotency_key`. On `map_beats` it replays the original
job. On the other four it only prevents a duplicate charge on a warm call, and it does nothing at
all on a cold one. See
[what an Idempotency-Key does](/docs/errors-and-limits#what-an-idempotency-key-does-endpoint-by-endpoint).

## match\_music

Match licensed music to a video. Wraps `POST /v1/match`. Returns a ranked shortlist, each track
carrying a `track_id` you can pass to `loop_music` or `get_sync_points`.

<ParamField path="video_id" type="string">
  A video you have already ingested. Provide this or `video_url`.
</ParamField>

<ParamField path="video_url" type="string">
  A public video URL. This ingests only, see the note below.
</ParamField>

<ParamField path="top_k" type="integer" default="20">
  A maximum, not a count. The shortlist is planned at 10 tracks, so any value above 10 still
  returns at most 10. A smaller value slices the same ranked list.
</ParamField>

<ParamField path="mood" type="string">
  A mood to steer the search, for example "uplifting" or "tense".
</ParamField>

<ParamField path="creative_direction" type="string">
  A free-text brief for the vibe, for example "warm indie folk, no vocals".
</ParamField>

<ParamField path="include_sync" type="boolean" default="false">
  Embed sync points on every returned track. Cache-only, so see the note under
  [get\_sync\_points](#get_sync_points).
</ParamField>

<ParamField path="engine" type="string">
  Advanced. Request a specific analysis engine. Clamped to your account's tier, so leave it unset
  to use your default.
</ParamField>

<Warning>
  `video_url` does not match in one step. It ingests the video and returns a `video_id` with a
  `poll` of `/v1/videos/{video_id}`, and there is no `job_id` in that response. Poll it with
  `check_video_status`, then call `match_music` again with the `video_id`. Passing that response to
  `check_job` gives it nothing to poll.
</Warning>

```json Example call theme={null}
{ "video_id": "69e5a58e73370adad5a10dc9", "top_k": 5, "creative_direction": "warm indie folk" }
```

```json Result theme={null}
{
  "video_id": "69e5a58e73370adad5a10dc9",
  "lane": {
    "title": "Soundstripe Match",
    "reasoning": "…",
    "matches": [
      {
        "track_id": "14792", "title": "…", "artist_name": "…", "score": 1.0,
        "bpm": 120, "instrumental": true, "duration_seconds": 164.01,
        "fit_explanation": "…"
      }
    ]
  }
}
```

A `video_id` whose analysis is not cached yet returns `202` with a `job_id` and a `poll` of
`/v1/match/jobs/{job_id}` instead. Poll it with `check_job(job_id, "match")`.

## underscore

Match licensed music to a script. No video needed. Wraps `POST /v1/underscore`. Returns a ranked
shortlist, each track with its title, artist, tempo, its Soundstripe song id, and a `match_map`
showing which words pulled which sound. Synchronous, and billed flat per call. A call that comes
back with no tracks bills nothing. Like every Cue tool it returns identity and analysis, not audio.

<ParamField path="script" type="string" required>
  The short-form script or copy to score. Up to 7000 characters.
</ParamField>

<ParamField path="platform" type="string">
  Optional platform hint (`tiktok`, `reels`, `shorts`, `linkedin`, `youtube`). Steers pace and
  genre.
</ParamField>

<ParamField path="mood" type="string">
  A mood to steer the search.
</ParamField>

<ParamField path="creative_direction" type="string">
  A free-text brief for the vibe.
</ParamField>

The shortlist is a fixed size. There is no amount parameter, because the price is flat and the work
is the same either way.

```json Example call theme={null}
{ "script": "You've been folding your shirts wrong. Here's the fold in three moves. Follow for more." }
```

```json Result theme={null}
{
  "lane": {
    "title": "Soundstripe Match",
    "matches": [
      {
        "track_id": "14792", "title": "…", "artist_name": "…", "score": 0.98,
        "bpm": 120, "instrumental": true, "duration_seconds": 164.01,
        "match_map": [{ "video": "punchy hook", "audio": "driving 120 BPM", "score": 1.0 }]
      }
    ]
  }
}
```

There is no `video_id` on this response. There was no video.

## loop\_music

Stretch a short track to cover a long video, as a downbeat-aligned, integer-bar self-loop with
musical seams and fades that covers the whole clip. Wraps `POST /v1/loop`. Soundstripe tracks only.
A video that is not long enough returns `applicable: false` and is not billed.

<ParamField path="video_id" type="string" required>
  The long video to score.
</ParamField>

<ParamField path="track_id" type="string" required>
  A track to loop. This is the Soundstripe song id, so one from your own catalog works.
</ParamField>

<ParamField path="auto_mix" type="boolean" default="false">
  Also return a `mix` block. If the video has a voiceover, this is how to sit the music under it.
  Render `mix.duck.envelope_points` as attenuation on the music, interpolating linearly between
  points. `voiceover: false` means there is nothing to duck. Adds no charge. Full recipe on
  [Loop, auto-mix](/docs/loop#duck-under-voiceover-auto-mix).
</ParamField>

<Note>
  The first call for a given `(video_id, track_id)` pair is cold. It returns `{status:
      "processing", job_id, poll}` and nothing else, and it bills the higher cold rate. Poll it with
  `check_job(job_id, "loop")`. Every later call for that same pair returns the plan directly at the
  low rate. A track you have already looped over one video is cold again on the next video.
</Note>

```json Example call theme={null}
{ "video_id": "69e5a58e73370adad5a10dc9", "track_id": "14792", "auto_mix": true }
```

```json Result (warm) theme={null}
{
  "video_id": "69e5a58e73370adad5a10dc9",
  "track_id": "14792",
  "track": { "soundstripe_song_id": "14792", "soundstripe_audio_file_id": "88031" },
  "applicable": true,
  "plan": {
    "music_in": 0.0, "music_out": 540.0, "music_end": 537.2,
    "loop_start_a": 12.6, "loop_end_b": 41.4, "loop_seconds": 28.8,
    "bars": 16, "repeats": 18, "seam_score": 0.94,
    "segments": [{ "song_from": 0.0, "song_to": 41.4 }],
    "start_fade_seconds": 0.5, "end_fade_seconds": 2.0,
    "alternates": []
  }
}
```

Over MCP the plan drops `plan.options`, the full copies of every alternative, because they are
duplicates that would eat an agent's context. `plan.alternates` carries a summary of each one
instead, in the same fields as the chosen plan. REST keeps `options` in full.

See [Loop](/docs/loop) for how to read the plan and
[Render loops under your own license](/docs/own-license) for how to turn it into audio.

## get\_sync\_points

Where to cut. Labeled sync offsets for one track against a video, best first. Wraps
`POST /v1/align`.

<ParamField path="video_id" type="string" required>
  The video to align against.
</ParamField>

<ParamField path="track_id" type="string" required>
  A track from `match_music`, or a Soundstripe song id you already hold.
</ParamField>

<ParamField path="top_k" type="integer" default="12">
  How many sync points to return, up to 50. The response is a slice of one ranked list, so asking
  for more gives you further down the same ranking.
</ParamField>

<ParamField path="auto_mix" type="boolean" default="false">
  Also return the `mix` block, the same one `loop_music` returns. Adds no charge.
</ParamField>

<Note>
  A cold call returns `{status: "processing", job_id, poll}` and bills the higher cold rate. Poll it
  with `check_job(job_id, "align")`. Warm needs either the points for that exact `(video_id,
      track_id)` pair to be cached, or both profiles Cue would score them from: the video's sync profile
  and the track's audio profile. The video's profile is warmed in the background after ingest and
  that warm is best-effort, so a track you have aligned many times can still come back cold on a new
  video.
</Note>

```json Example call theme={null}
{ "video_id": "69e5a58e73370adad5a10dc9", "track_id": "14792", "top_k": 6 }
```

```json Result (warm) theme={null}
{
  "video_id": "69e5a58e73370adad5a10dc9",
  "track_id": "14792",
  "cached": true,
  "sync_points": [
    { "offset_seconds": 12.03, "score": 0.91, "label": "Peak" },
    { "offset_seconds": 27.44, "score": 0.74, "label": "" }
  ]
}
```

`label` names the musical moment the clip opens on, and it is one of `Intro`, `Build`, `Peak`,
`Breakdown` or `Outro`. It is `""` for an ordinary mid-song moment, which is common, and `null`
when the energy fallback produced the point rather than the beat-sync engine. It is never "Drop",
deliberately: a drop is a genre-specific event and most of the catalog does not have one, so the
biggest-energy moment is called `Peak` in every genre.

`score` orders this response and nothing else. Do not compare it across tracks.

## map\_beats

Map a track's beats and downbeats. Send audio, get the grid. Wraps `POST /v1/beats`.

<ParamField path="audio_url" type="string" required>
  An `https://` or `s3://` URL to the audio. A presigned link from your own storage works.
</ParamField>

<ParamField path="track_id" type="string">
  Your own identifier, echoed back on the job so you can reconcile a batch without keeping your own
  job table.
</ParamField>

<ParamField path="known_bpm" type="number">
  Your catalog's tempo, between 20 and 400. Worth a large accuracy gain when it is right. Pass it
  only if you trust the octave, since a value at half or double the real tempo is worse than
  nothing. Cue verifies it against its own reading and ignores it when the two disagree by an
  octave.
</ParamField>

<ParamField path="audio_sha256" type="string">
  The SHA-256 of the audio bytes, 64 hex characters. When this account has already mapped those
  exact bytes, the job resolves from cache without fetching the file.
</ParamField>

```json Example call theme={null}
{ "audio_url": "https://your-storage/track.mp3", "track_id": "your-own-id" }
```

Always async. Returns `{ job_id, status: "processing", poll }`. Poll with
`check_job(job_id, "beats")`. Billed per minute of audio, once, on success. A failed job bills
nothing.

Over MCP a long grid is summarised to its first 400 beats, because a full one would exceed the
tool-output limit and fail the call. When that happens the response sets `beats_truncated: true`,
carries `beats_total` and `downbeats_total`, and points at `GET /v1/beats/{job_id}`, which returns
all of it. See [Map a track's beats](/docs/beats).

## ingest\_video

Add a video for reuse. Wraps `POST /v1/videos`. Returns a `video_id`. Poll
[`check_video_status`](#check_video_status) until it reads `ready`.

<ParamField path="video_url" type="string" required>
  The URL to fetch. Anything Cue can `GET` works, including a presigned link from your own storage,
  so you do not have to make the file public.
</ParamField>

```json Example call theme={null}
{ "video_url": "https://yourcdn.com/clip.mp4" }
```

```json Result theme={null}
{ "video_id": "69e5a58e73370adad5a10dc9", "status": "processing", "poll": "/v1/videos/69e5a58e73370adad5a10dc9" }
```

The call returns before a single byte is downloaded, so a source that is too large or is not a
video fails later, on the video row, not here. Watch `check_video_status` for it.

<Note>
  Holding the bytes locally instead? That is the REST upload flow. `POST /v1/videos` with a
  filename for a presigned upload URL, `PUT` the bytes, then `POST /v1/videos/{video_id}/ingest` to
  start processing. It is not exposed over MCP on purpose, because the last step has no tool, so an
  agent that started it here could never finish and would poll a video that never began processing.
</Note>

## check\_video\_status

Is a video `processing`, `ready`, or `failed`? Wraps `GET /v1/videos/{id}`. Never billed.

<ParamField path="video_id" type="string" required>
  The video to check.
</ParamField>

```json Result (ready) theme={null}
{ "video_id": "69e5a58e73370adad5a10dc9", "status": "ready", "ready": true, "title": "clip", "reason": null }
```

```json Result (failed) theme={null}
{ "video_id": "69e5a58e73370adad5a10dc9", "status": "failed", "ready": false, "title": "clip", "reason": "source_too_large: Video exceeds the size limit." }
```

`reason` is populated only on `failed` and is prefixed with a token you can switch on. See
[Errors & rate limits](/docs/errors-and-limits) for the list.

## check\_job

Poll an async job returned by `match_music`, `loop_music`, `get_sync_points` or `map_beats`.
Returns `processing` until it is done, then the result. The job bills once, on completion, so
polling adds nothing and skipping it saves nothing.

<ParamField path="job_id" type="string" required>
  The `job_id` from the tool that returned `status: "processing"`. A bare UUID, or 32 hex
  characters for a beats job.
</ParamField>

<ParamField path="kind" type="string" default="match">
  Which job: `"match"`, `"loop"`, `"align"` or `"beats"`. Anything else is treated as `"match"`.
</ParamField>

```json Example call theme={null}
{ "job_id": "9f2c1a4e-3b77-4e21-8f0d-2a1c6b4e9d55", "kind": "loop" }
```

```json Result (still running) theme={null}
{ "video_id": "69e5a58e73370adad5a10dc9", "job_id": "9f2c1a4e-…", "status": "processing", "poll": "/v1/loop/jobs/9f2c1a4e-…", "status_code": 202 }
```

```json Result (complete) theme={null}
{ "video_id": "69e5a58e73370adad5a10dc9", "job_id": "9f2c1a4e-…", "status": "complete", "cached": false, "applicable": true, "plan": { "…": "…" } }
```

A failed job comes back as a tool error carrying the worker's reason, not as a `status: "failed"`
result. Loop, sync and match jobs fail as `502`. A beats job fails as `422`, and so does one that
was never picked up, which was not billed and can simply be resubmitted.
