unsloth/studio/backend/studio_mcp/inputs.py
Etherl 904a615476
Studio: MCP returns key-free download links and finds media, voices and video frames again (#14079)
## Summary

Agents using Unsloth Studio over MCP could not get generated media out
without the API key, had no way to find earlier images, clips, videos,
transcripts or saved voices, and could not use a gallery video as an
input. Every image, audio clip and video an MCP tool returns now carries
a `download_url` that works without the key for about an hour. Two new
tools, `list_media` and `voices`, page through the galleries and manage
saved voices. A gallery video can be passed as an image input (one
frame) or an audio input (its sound).

## Changes

Signed links

- New mint routes: `GET /api/inference/images/gallery/{id}/signed-url`
(reuses the existing 1 h image signer) and `GET
.../audio/gallery/{id}/signed-url` plus `.../file-signed` on both the
`/api/inference` and `/v1` mounts. Audio tokens use their own secret, so
a token for one media type never serves another.
- The video `signed-url` mint takes an optional `ttl_s` (60 s to 12 h).
MCP asks for 3600. The UI still gets 12 h.
- Minting is refused (403) for requests admitted without a credential,
and another account's id returns 404, the same as the existing video
mint. The keyless file routes bind the token's account with `run_as`.
- Secrets are per process, so links stop working when Unsloth Studio
restarts. `MCP.md` tells agents to call `list_media` for a fresh one.

MCP outputs

- `ImageItem`, `AudioClip` and `VideoInfo` gain `download_url` next to
the existing `url`, and the `ResourceLink` in each result points at
`download_url` when one was minted.
- `generate_image`, `generate_audio` and `get_job(kind="video")` take
`return_media` (`"inline"`, `"preview"` or `"link"`) and `preview_px`
(32 to 1024). Images and video keep the current `"inline"` default.
`"preview"` on a video returns its first frame at `preview_px`.
- `generate_audio` now defaults to `"link"` instead of inlining WAVs up
to 1.5 MiB. Pass `return_media="inline"` to get the old behavior for
short clips.
- `transcribe` returns `transcript_id` from the saved record, and
`AudioClip` carries `workflow`, `voice_id`, `source_clip_id` and `seed`
when the route sends them.

New tools (`studio_mcp/tools/library.py`)

- `list_media(kind, limit, cursor)` lists images, audio, videos,
transcripts or voices, newest first. Cursors are prefixed with their
kind and validated before any call, so a cursor from another kind or a
malformed one is refused with a clear error.
- `voices(action=list|save|rename|delete)` wraps the existing
`/v1/audio/voices` routes. Saving from a `voice_id` is refused locally,
matching the route.

Video as input

- `ImageInput.video_id` with `frame` (`"first"`, `"last"` or seconds)
reads one frame through the new `GET
/api/inference/video/gallery/{id}/frame` route. The frame is decoded
with PyAV and is not saved to the Images gallery. Frames above 8192x8192
are refused.
- `AudioInput.video_id` with optional `start_s`/`end_s` calls the new
`POST /api/inference/video/gallery/{id}/audio-input`, which stores the
trimmed track in the existing audio-input store and returns its id. The
same cut again returns the existing record (200 instead of 201). A video
without sound returns 400 "This video has no audio track."

`studio/MCP.md` lists the two new tools and rewrites the "Media and
files" section for `download_url`, `return_media` and video inputs.

## Testing

New test files:

- `tests/test_media_signed_links.py`: a minted link fetches with no
credential, serves Range requests, stops at expiry, serves only the
minting account, lasts 1 h for MCP and 12 h for the UI video link, and
cannot be minted by a keyless caller.
- `tests/test_video_gallery_inputs.py`: first, last and timed frames
from real MP4 fixtures, a trimmed and idempotent audio extract, and a
silent video.
- `tests/test_mcp_return_media.py`: each `return_media` mode for images,
audio and video, `preview_px` bounds, `signed_url` rebuilding the path
from the token only, `transcript_id`, and video ids as image and audio
inputs.
- `tests/test_mcp_tools_library.py`: `list_media` paging and cursor
validation for every kind, and each `voices` action.
- `tests/test_mcp_media_links_e2e.py`: real routers behind the MCP
server. Generated and listed media download without the key, the last
frame of one clip starts the next, and a clip's sound becomes a saved
voice.

Updated tests: the audio case in `test_account_media_isolation.py`, the
new mints in the keyless guard list in `test_keyless_api_access.py`, the
multi-account route factories, host-path and tool-contract cases for the
new tools, and exact-output asserts in the image, audio and video tool
tests.

```bash
cd studio/backend
python -m pytest tests/test_media_signed_links.py tests/test_video_gallery_inputs.py tests/test_mcp_return_media.py tests/test_mcp_tools_library.py tests/test_mcp_media_links_e2e.py tests/test_account_media_isolation.py tests/test_keyless_api_access.py tests/test_mcp_server.py tests/test_mcp_tool_contract.py tests/test_mcp_no_host_paths.py tests/test_mcp_tools_images.py tests/test_mcp_tools_audio.py tests/test_mcp_tools_video.py tests/multi_account -q
```

Results on this head: the command above plus every
`tests/test_mcp_*.py`, `test_video_routes.py`, `test_video_gallery.py`,
`test_audio_inputs.py`, `test_openai_images_generations_route.py` and
`test_account_signed_links.py`: 2,909 passed, 3 skipped.

Live run against a real Unsloth Studio (this branch, one B200), driven
through a plain MCP client that does not download links itself:

- A fresh agent made two Z-Image-Turbo images, a Kokoro narrator line
and a 3 s Wan2.2 TI2V 5B clip, saved the line as the voice "Narrator",
and put all four files in `./assets` with plain `curl` on
`download_url`. The API key never appeared on a command line. The
`generate_audio` answer was 940 bytes, where the inlined WAV would have
been about 400 KB of base64.
- A second fresh agent, with no access to the first one's files, found
the hero image with `list_media` and the voice with `voices`. It redrew
the hero from `init_image={"gallery_id": ...}` and spoke a new line with
`reference={"voice_id": ...}`, with no host paths and nothing
regenerated.
- `generate_video(first_frame={"video_id": <clip A>, "frame": "last"})`
started clip B on clip A's last frame: a mean pixel difference of 1.76
between those two frames, against 54 and 71 for the control pairs. The
minted video link expired 3,599 s after minting.

---------

Co-authored-by: Daniel Han <23090290+danielhanchen@users.noreply.github.com>
Co-authored-by: danielhanchen <danielhanchen@gmail.com>
2026-10-18 18:14:49 -07:00

240 lines
11 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Media an agent hands to a tool: by Unsloth Studio id, as inline data, or as a file path. A path makes the server read a file on the Unsloth Studio computer, so it is honoured only when the agent itself runs there (a direct loopback /mcp request); anyone else gets told to send the bytes or an id, and the file is never touched."""
from __future__ import annotations
import base64
import binascii
import re
from pathlib import Path
from typing import Annotated, Any, Literal, Optional, Union
from fastmcp.exceptions import ToolError
from pydantic import BaseModel, ConfigDict, Field, model_validator
from studio_mcp.caller import Caller
from studio_mcp.errors import raise_for_route
from studio_mcp.forward import forward
from studio_mcp.media import image_gallery_path, video_frame_path, video_gallery_path
PATH_REMOTE = (
"File paths work only when the agent runs on the Unsloth Studio computer. "
"Send data_url/data_base64 or an Unsloth Studio id instead."
)
MAX_IMAGE_BYTES = 32 * 1024 * 1024 * 3 // 4
IMAGE_MIMES = ("image/png", "image/jpeg", "image/webp")
_DATA_URL = re.compile(r"^data:(image/(?:png|jpeg|webp));base64,(.*)$", re.DOTALL)
_MEDIA_ID = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
class ImageInput(BaseModel):
"""Exactly one of ``path`` (a file on the Unsloth Studio computer), ``data_url`` (data:image/png|jpeg|webp;base64,...), ``gallery_id`` (an image in the Unsloth Studio Images gallery) or ``video_id`` (one frame of a clip in the Unsloth Studio Video gallery, picked by ``frame``: "first", "last" or seconds from the start; default "first"). Inline data rides in the MCP request, which is limited to 4 MiB."""
model_config = ConfigDict(extra = "forbid")
path: Optional[str] = None
data_url: Optional[str] = None
gallery_id: Optional[str] = None
video_id: Optional[str] = None
frame: Optional[Union[Literal["first", "last"], Annotated[float, Field(ge = 0)]]] = None
@model_validator(mode = "after")
def _exactly_one(self) -> "ImageInput":
sources = (self.path, self.data_url, self.gallery_id, self.video_id)
if sum(value is not None for value in sources) != 1:
raise ValueError("Give exactly one of path, data_url, gallery_id or video_id.")
if self.frame is not None and self.video_id is None:
raise ValueError("frame goes with video_id.")
if self.video_id is not None and not _MEDIA_ID.match(self.video_id):
raise ValueError("Unsloth Studio video ids are letters, digits, - and _.")
return self
def sniff_image(data: bytes) -> Optional[str]:
if data.startswith(b"\x89PNG\r\n\x1a\n"):
return "image/png"
if data.startswith(b"\xff\xd8\xff"):
return "image/jpeg"
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return "image/webp"
return None
def read_local(caller: Caller, path: str, max_bytes: int) -> bytes:
"""A file named by the agent, read only for a caller on this computer and only when within ``max_bytes``."""
if not caller.direct_local:
raise ToolError(PATH_REMOTE)
file = Path(path).expanduser()
try:
size = file.stat().st_size
except OSError:
raise ToolError(f"Cannot read {path}") from None
if not file.is_file():
raise ToolError(f"{path} is not a file")
if size > max_bytes:
raise ToolError(f"{path} is larger than {max_bytes // (1024 * 1024)} MiB")
try:
return file.read_bytes()
except OSError:
raise ToolError(f"Cannot read {path}") from None
def decode_base64(data: str, max_bytes: int, what: str) -> bytes:
# Checked on the encoded length, before anything is decoded.
if len(data) > 4 * ((max_bytes + 2) // 3):
raise ToolError(f"{what} is larger than {max_bytes // (1024 * 1024)} MiB")
try:
return base64.b64decode(data, validate = True)
except (binascii.Error, ValueError):
raise ToolError(f"{what} is not valid base64") from None
async def resolve_image(
caller: Caller,
image: ImageInput,
*,
max_bytes: int = MAX_IMAGE_BYTES,
mimes: tuple[str, ...] = IMAGE_MIMES,
) -> tuple[bytes, str]:
"""The image's bytes and type."""
if image.path is not None:
data = read_local(caller, image.path, max_bytes)
elif image.data_url is not None:
match = _DATA_URL.match(image.data_url)
if match is None:
raise ToolError("data_url must be data:image/png, image/jpeg or image/webp;base64,...")
data = decode_base64(match.group(2), max_bytes, "data_url")
elif image.video_id is not None:
at = image.frame if image.frame is not None else "first"
response = await forward(
caller, "GET", video_frame_path(image.video_id), params = {"at": str(at)}
)
raise_for_route(response)
data = response.content
if len(data) > max_bytes:
raise ToolError(f"A frame of video {image.video_id} is too large to send")
else:
response = await forward(caller, "GET", image_gallery_path(image.gallery_id))
raise_for_route(response)
data = response.content
if len(data) > max_bytes:
raise ToolError(f"Gallery image {image.gallery_id} is too large to send")
mime = sniff_image(data)
if mime not in mimes:
allowed = ", ".join(m.removeprefix("image/").upper() for m in mimes)
raise ToolError(f"Images must be {allowed}")
return data, mime
async def resolve_images(
caller: Caller, images: list[ImageInput], *, total_cap: int, too_large: str, **limits: Any
) -> list[str]:
"""Each image as a data URL, refused with ``too_large`` as soon as their bytes together pass ``total_cap``."""
urls, total = [], 0
for image in images:
data, mime = await resolve_image(caller, image, **limits)
total += len(data)
if total > total_cap:
raise ToolError(too_large)
urls.append(data_url(data, mime))
return urls
def data_url(data: bytes, mime: str) -> str:
return f"data:{mime};base64,{base64.b64encode(data).decode('ascii')}"
MAX_AUDIO_BYTES = 200 * 1024 * 1024
_AUDIO_ID = _MEDIA_ID
class AudioInput(BaseModel):
"""Exactly one of ``path`` (a file on the Unsloth Studio computer), ``data_base64`` with a ``filename``, or an Unsloth Studio id: ``input_id`` (an uploaded clip), ``clip_id`` (an Audio history clip), ``voice_id`` (a saved voice) or ``video_id`` (the sound of a clip in the Unsloth Studio Video gallery, optionally trimmed to ``start_s``..``end_s`` seconds). Inline data rides in the MCP request, which is limited to 4 MiB."""
model_config = ConfigDict(extra = "forbid")
path: Optional[str] = None
data_base64: Optional[str] = None
filename: Optional[str] = None
input_id: Optional[str] = None
clip_id: Optional[str] = None
voice_id: Optional[str] = None
video_id: Optional[str] = None
start_s: Optional[Annotated[float, Field(ge = 0, le = 3600)]] = None
end_s: Optional[Annotated[float, Field(gt = 0, le = 3600)]] = None
@model_validator(mode = "after")
def _exactly_one(self) -> "AudioInput":
sources = (
self.path,
self.data_base64,
self.input_id,
self.clip_id,
self.voice_id,
self.video_id,
)
if sum(value is not None for value in sources) != 1:
raise ValueError(
"Give exactly one of path, data_base64, input_id, clip_id, voice_id or video_id."
)
if (self.filename is not None) != (self.data_base64 is not None):
raise ValueError("filename goes with data_base64, and data_base64 needs one.")
if (self.start_s is not None or self.end_s is not None) and self.video_id is None:
raise ValueError("start_s and end_s go with video_id.")
if self.end_s is not None and self.end_s <= (self.start_s or 0):
raise ValueError("end_s must be after start_s.")
for value in (self.input_id, self.clip_id, self.voice_id, self.video_id):
if value is not None and not _AUDIO_ID.match(value):
raise ValueError("Unsloth Studio audio ids are letters, digits, - and _.")
return self
@property
def is_upload(self) -> bool:
return self.path is not None or self.data_base64 is not None
def audio_bytes(caller: Caller, audio: AudioInput) -> tuple[bytes, str]:
"""The bytes and a display name of an audio input given as a path or inline data."""
if audio.path is not None:
return read_local(caller, audio.path, MAX_AUDIO_BYTES), Path(audio.path).name
return decode_base64(audio.data_base64, MAX_AUDIO_BYTES, "data_base64"), audio.filename
async def upload_audio(caller: Caller, data: bytes, name: str) -> str:
"""Store audio with Unsloth Studio and return its input id. Raw bytes, so the upload route sees a Content-Length; a re-upload of the same audio answers 200 with the existing id."""
if len(data) > MAX_AUDIO_BYTES:
raise ToolError("Audio is larger than 200 MiB")
response = await forward(
caller, "POST", "/v1/audio/inputs", params = {"name": name[:255] or "audio"}, content = data
)
record = raise_for_route(response)
if not isinstance(record, dict) or not isinstance(record.get("id"), str):
raise ToolError("Unsloth Studio did not store the audio")
return record["id"]
async def audio_ref(caller: Caller, audio: AudioInput) -> dict[str, str]:
"""The ``{input_id|clip_id|voice_id}`` reference an Unsloth Studio audio route takes, uploading first when needed."""
if audio.is_upload:
data, name = audio_bytes(caller, audio)
return {"input_id": await upload_audio(caller, data, name)}
if audio.video_id is not None:
# Unsloth Studio stores the clip's sound as an audio input; the same trim answers the same id.
trim = {"start_s": audio.start_s, "end_s": audio.end_s}
response = await forward(
caller,
"POST",
video_gallery_path(audio.video_id, "/audio-input"),
json_body = {key: value for key, value in trim.items() if value is not None},
)
record = raise_for_route(response)
if not isinstance(record, dict) or not isinstance(record.get("id"), str):
raise ToolError("Unsloth Studio did not store the video's audio")
return {"input_id": record["id"]}
for key in ("input_id", "clip_id", "voice_id"):
value = getattr(audio, key)
if value is not None:
return {key: value}
raise ToolError("No audio was given")