Articles › AI

Lessons from a real MCP server: 18 tools an AI uses to make videos

Designing an MCP server an AI uses well: group_verb names, quota check beside paid tools, job ids for slow work, paths not base64, env-only secrets, confirmed publishing

Intermediate 8 min read October 21, 2026

What you will learn
  • Name tools <group>_<verb> and write docstrings that state cost, duration and prerequisites, because the AI chooses from that text alone
  • Work that takes minutes should return an id with a separate status tool; files travel as paths in a shared volume, never as base64
  • Secrets come from env vars only, never through tool arguments or results, and irreversible tools such as publishing stay separate and need a human yes
  • One codebase can serve two transports (SSE and stdio) so different agent systems share the same server

In EP6 of the Claude series we wrote a ten-line MCP server that lets an AI search notes. This article goes to the other end: a server in daily use on a home lab machine, with 18 tools that a team of agents uses to make short DevMakerX videos, from writing the script and generating speech, through lip-sync video and subtitles, to uploading on YouTube and TikTok. The real one costs money on every wrong call, takes minutes per step, and has tools you cannot undo, so these lessons are about designing for correct use by an AI, not about the protocol.

The server is Python 3.11 with FastMCP from the mcp library, running in a python:3.11-slim Docker image with ffmpeg and a Thai font installed.

Overview: 18 tools in 6 groups

GroupToolsJobCosts money
character_*list, get, get_script_promptDatabase of 17 characters: persona, voice, look, and a script-writing promptNo
tts_*list_voices, generate_speechThai text to speechYes, per character
image_*generate, generate_characterCharacter face imagesYes, per credit
video_*check_quota, upload_talking_photo, create_lipsyncLip-sync video from a photo plus audioYes, per second
audio_* / video_post_processtranscribe, post_processSpeech to subtitles; subtitles, music, logo via ffmpegNo, runs locally
publish_*list_platforms, auth_url, auth_callback, upload_youtube, upload_tiktok, statusOAuth and uploadsNo, but irreversible
AI agentcharacter_*character datatts_*speechimage_*imagesvideo_*videopost_*subs, music, logopublish_*post onlineneeds a human OKvideo_check_quotacheck first, freevideo_create_lipsynccosts money
Grouped by job; paid tools get a check beside them; irreversible ones stand apart

Lesson 1: name tools group_verb

The AI never sees our source files, only a list of tool names and descriptions. With 18 tools called generate, make, process and upload in a jumble, it picks wrong often. With <group>_<verb> the list sorts itself into blocks: sound is tts_*, uploading is publish_*.

A bonus: seeing publish_auth_url and publish_auth_callback next to publish_upload_youtube, the AI guesses correctly that authorization comes before upload, with no explanation needed.

Lesson 2: a “check before you spend” tool beside every paid tool

Lip-sync video is billed per second, and running out of quota halfway wastes the audio you already paid for. So video_check_quota sits next to video_create_lipsync, and the docstring states cost and duration plainly.

@mcp.tool()
def video_check_quota() -> dict:
    """Check remaining lip-sync quota (seconds). Free. Call before video_create_lipsync."""
    return check_quota()

@mcp.tool()
def video_create_lipsync(talking_photo_id: str, audio_path: str, output_name: str | None = None) -> dict:
    """Create a lip-sync video from a face photo and an MP3.

    COSTS quota: about 1 quota second per second of audio. Takes 3-5 minutes.
    Call video_check_quota first; if remaining < audio length, stop and tell the user.
    """

The same rule pairs tts_list_voices (free) with tts_generate_speech (paid). The line that matters says what it costs, how much, and what to call first. The AI decides from that text, not from the code inside.

Lesson 3: slow work returns an id, then you ask for status

At first video_create_lipsync did everything in one call: upload audio, request the video, poll every 5 seconds for up to 5 minutes. One agent system dropped the connection before the job finished because its default timeout was shorter. We raised that agent’s timeout to 300 seconds, a fix in the wrong place.

The right shape is what the publish_* group already does: the upload tool returns a publish_id at once, and a separate publish_status tool answers about it.

@mcp.tool()
def publish_upload_tiktok(video_path: str, title: str = "", privacy_level: str = "SELF_ONLY") -> dict:
    """Start a TikTok upload. Returns publish_id at once; poll with publish_status."""
    ...
    return {"success": True, "publish_id": publish_id, "status": "PROCESSING"}

@mcp.tool()
def publish_status(platform: str, publish_id: str) -> dict:
    """Check an upload started earlier. Cheap; call every 10-20 s until status is done or failed."""

Anything longer than a minute should be split into “start” and “check status”. Let the AI do the waiting, not the connection.

Lesson 4: files travel as paths in a shared volume, never as base64

One minute of speech is roughly a 1 MB MP3; one minute of 1080x1920 video is 20 to 40 MB. Return that as base64 and the whole blob lands in the AI’s context, expensive and easy to overflow.

This server writes files to a mounted volume and returns only the path. Every tool takes paths in and gives paths out.

OUTPUT_DIR = os.environ.get("MEDIA_OUTPUT_DIR", "/data/media")

def generate_speech(text, voice_id, output_name="speech", **settings) -> dict:
    out_dir = Path(OUTPUT_DIR) / "audio" / date.today().isoformat()
    out_dir.mkdir(parents=True, exist_ok=True)
    out = out_dir / f"{output_name}.mp3"
    out.write_bytes(call_tts_api(text, voice_id, settings))
    return {"success": True, "path": str(out), "size_bytes": out.stat().st_size}

The next tool in the chain checks the file exists before doing anything, and returns a readable error such as {"success": False, "error": "Audio not found: ..."}. The AI reads that and knows to go back a step.

Lesson 5: secrets live in env vars only

The server holds API keys for three services and OAuth client credentials for two platforms, all read from environment variables at startup. No tool accepts a key as an argument and none returns one; even the status tool only says whether credentials exist.

API_KEY = os.environ.get("HEYGEN_API_KEY", "")

def check_quota() -> dict:
    if not API_KEY:
        return {"success": False, "error": "HEYGEN_API_KEY not set"}
    ...

def list_connected() -> list[dict]:
    return [{"platform": p, "has_credentials": bool(os.environ.get(cfg["client_id_env"]))}
            for p, cfg in PLATFORMS.items()]

This is not only about leaks. Everything that passes through a tool ends up in the agent’s logs, in the model’s context, and sometimes at another provider’s model. A key that went through an argument once is exposed. OAuth tokens live as files in a separately mounted volume; the server refreshes them itself, and the AI never sees one.

Lesson 6: two transports from one codebase

The home lab runs two kinds of agent system. One speaks MCP over the network (SSE). The other only supports stdio: it launches the server as a child process and talks over stdin/stdout. But the server must live in its own container, where ffmpeg, the 1.5 GB transcription model and the keys are.

So the server serves SSE on a port as the main door, and the stdio-only system uses mcp-proxy, a small program that looks like a stdio server to the agent but connects over SSE to the real one. The server supports both with one line.

transport = os.environ.get("TRANSPORT", "sse")
mcp.run(transport=transport)   # "sse" or "stdio"

The stdio-only agent is configured like this (the server address is a name on the internal Docker network):

media:
  command: uvx
  args: ["mcp-proxy", "http://<media-mcp-in-docker-network>/sse"]

One server, no second copy to maintain, and the SSE port is bound to the machine’s 127.0.0.1 only, never exposed outside.

Lesson 7: publishing is irreversible

A wrong tts_generate_speech call wastes a few cents. A wrong publish_upload_youtube call puts a half-finished video on the real channel. We did three things.

  1. Publishing is its own publish_* group. There is no single “make video and upload” tool.
  2. Defaults are always private: privacy_status="private" and SELF_ONLY. If the AI passes nothing, nobody sees the video.
  3. The docstring says a human must confirm first, and the agent team’s rules say it may never switch to public on its own.
@mcp.tool()
def publish_upload_youtube(video_path: str, title: str, description: str = "",
                           privacy_status: str = "private", is_short: bool = False) -> dict:
    """Upload a video to YouTube. IRREVERSIBLE: the video appears on the real channel.

    Before calling: show the user the final video path, title and privacy, and get an
    explicit yes. Default privacy is "private"; never set "public" without the user asking.
    """

Where the API supports it, label the upload as synthetic media too. Both platforms have a flag for it.

Worked example: from topic to subtitled video

The AI is told “make a 60-second clip of Lynn on 3D printing tips”. What it does, with nobody dictating the order:

  1. character_get_script_prompt("lynn", topic="3D printing tips", duration_sec=60) returns a prompt; the AI writes a script of about 900 Thai characters (spoken Thai runs around 15 characters per second).
  2. tts_generate_speech(text=script, agent_id="lynn"). The server resolves the voice from the character profile and returns /data/media/audio/2026-10-21/lynn.mp3, 0.9 MB.
  3. video_check_quota() reports 310 seconds left; the script is 61 seconds, so it calls video_create_lipsync(talking_photo_id, audio_path=".../lynn.mp3") and gets /data/media/videos/2026-10-21/lynn.mp4.
  4. audio_transcribe(".../lynn.mp3") returns 14 timed segments, then video_post_process(input_video=".../lynn.mp4", subtitle_segments=..., logo_text="DevMakerX") produces lynn_final.mp4.

It ends by reporting the final path to a person and does not touch publish_* on its own. Every step talks in paths only, and step 4 costs nothing, so subtitles can be fixed and re-rendered as often as needed.

Common mistakes

  • One tool that goes from generation to upload, leaving the AI no stopping point for a human check
  • A docstring that says only “create video”, without cost, duration or prerequisites
  • Blocking the connection for minutes and then raising the client timeout instead of returning an id
  • Returning files as base64, or stuffing whole files into arguments
  • Accepting keys or tokens through arguments “for convenience”, after which they sit in the logs of every system they passed through
  • A publishing tool whose default is public
  • Keeping a service’s limits in your head, such as how many face photos can be uploaded, instead of in the docstring where the AI sees them before calling

FAQ

Why not one make_video tool that does every step inside the server?

You can, but you lose two things. First, the checkpoints where a person reviews work in progress, such as listening to the audio before paying for the video. Second, flexibility. Small tools passing paths let the AI reorder steps, for example redoing subtitles from the same audio without regenerating the video.

Can the AI forget to call the quota check before creating a video?

Yes. The docstring makes it call the check far more often, but it is not enforcement. If you need a guarantee, have the paid tool check quota again in code and refuse when it is short. The separate check tool still earns its place because it lets the AI plan before starting.

Why SSE at all? Could I use stdio only?

If the AI runs on the same machine as the server, stdio alone is fine. Here the server lives in its own container with ffmpeg, the transcription model and the keys, while the agents run in other containers, so it has to be reachable over the network. SSE is the main door; stdio through a proxy exists for a system that only speaks stdio.

Commands and settings were tested on sample systems. Try them on a test machine before production.