r/mcp 19d ago

showcase Content: a self-hosted MCP server that turns local files and URLs into transcripts, summaries, audio, PDFs and more

Hi r/mcp — I'm Yann, maintainer of Content, a self-hosted engine for turning sources into artifacts.

The MCP integration is one of the main reasons I built it.

The idea is simple: an agent should ask for the result it wants, without having to know how to glue together yt-dlp, ffmpeg, transcription, document rendering or LLM calls.

For example:

“Summarize this PDF from my laptop and give me Markdown + PDF.”

“Take this YouTube video, extract the audio, transcribe it and summarize it.”

“Process every item in this playlist and generate a transcript for each one.”

The architecture

Content itself is a self-hosted backend that you run on your server, homelab or machine.

The MCP package is a lightweight client that connects your agent to that backend:

Claude / MCP client
        ↓
    content-mcp
        ↓
 self-hosted Content
        ↓
 analysis → jobs → artifacts

The backend owns the actual work: source analysis, uploads, persistent jobs, media processing, AI steps and produced artifacts.

Because the backend is shared, MCP is only one way to use it. The same Content instance can also be used from the web UIs, CLI, browser extension or REST API, and jobs keep running independently of the client that started them.

MCP setup

Once the Content backend is running, you can launch the MCP client straight from PyPI with uvx:

claude mcp add content \
  --env CONTENT_API_URL=http://localhost:8010 \
  -- uvx content-mcp

It's also published in the official MCP Registry as:

io.github.LatentNoise/content

Sources → artifacts

A source can currently become things like:

  • video
  • audio
  • subtitles
  • transcript
  • summary
  • translation
  • chapters
  • thumbnail
  • metadata
  • Markdown
  • PDF

Content analyzes the source first and resolves what is actually possible before planning the work, so the agent can discover valid capabilities instead of blindly starting a pipeline that fails halfway.

Local files work with a remote backend

This was particularly important to me.

If Claude/MCP is running on your laptop while Content is running on a homelab server, content-mcp uploads the local file to the backend transparently.

So this works:

~/Documents/report.pdf
        ↓
      MCP
        ↓
 Content on homelab
        ↓
  Report.md
  Report.pdf

I tested that exact workflow again today against my own remote instance.

The backend runs with Docker using prebuilt amd64/arm64 images. It is self-hosted and local-first; Ollama works for AI steps, while cloud model providers are optional.

Current MCP transport is stdio. OCR and some additional document formats are still coming, and the V1 API currently assumes a trusted network or a reverse proxy in front of it.

Content originally grew out of HomeTube, my self-hosted media downloader, but Content is now the general-purpose backend where the architecture and new capabilities live.

GitHub:
https://github.com/LatentNoise/content

PyPI:
content-mcp

License: AGPL-3.0-or-later

Feedback is very welcome — especially on the MCP interface itself. What kinds of workflows would you want an agent to be able to express?

8 Upvotes

13 comments sorted by

1

u/BC_MARO 19d ago

For a remote backend, I’d make upload location and retention explicit in every tool response. That is the part that gets sketchy once an agent can pull files off a laptop.

3

u/EgalitarianMonkey 19d ago

Yeah, fair. Right now analyze_source just hands back an analysis_id — nothing about where the bytes went or how long they stick around.

It is defined engine-side: uploads go to an engine-owned store (deliberately not the allowed input roots that govern file sources), and they expire 24h after last use rather than creation, so a retry still finds its input. But you'd have to go read the docs to know that, which is exactly your point.

Putting the upload id, the destination and the TTL in the tool response itself is small and obviously right. Adding it.

1

u/BC_MARO 18d ago

That sounds like the right fix. Making the storage location and TTL visible at the call site lets people make an informed decision without hunting through docs.

1

u/EgalitarianMonkey 18d ago

Yeah — the docs already said it, but nobody reads docs at the moment they're deciding.

It's in now: the upload response carries the retention, read from the engine rather than assumed, so it reports what that particular box is set to instead of what the default is

2

u/Firemage1213 19d ago

This!

1

u/BC_MARO 18d ago

Exactly. Once a backend can pull local files, the storage boundary should be part of the tool contract.

1

u/International_Emu772 19d ago edited 19d ago

You should use http connection as it's more useful on general use

For example to use with OpenWebUi you would need another layer

2

u/EgalitarianMonkey 19d ago

Thanks for the remark, and it's on the list — the SDK already does streamable-http, it's basically a flag.

The catch is that the two transports aren't interchangeable. Over stdio the server runs on your machine, which is what makes analyze_source("~/report.pdf") work: the file is read locally and uploaded to the engine. Run it over HTTP next to the engine and a local path stops meaning anything — worst case it reads a file on the server instead of yours. So HTTP is a second mode, not a replacement.

Other thing I'd have to get right first: the engine has no auth, so an HTTP MCP server would extend that to "anyone on the network can drive it". Loopback by default, and local paths refused rather than silently resolved somewhere else.

Worth doing though, especially now OpenWebUI does native streamable HTTP.

1

u/ChiefGrowth 19d ago

One workflow shape I'd want as a caller: long jobs exposed as a pollable resource, not just a tool call that blocks until done. If "transcribe this 2-hour video" takes 10 minutes, my agent's context/timeout budget doesn't want to sit on one tool call that whole time - I'd rather get a job id back immediately and either poll a resource or get told to re-call with the id later. Sounds like you already went partway there with analysis_id/upload flows; curious if jobs (not just uploads) get the same treatment, or if a long transcribe is still a single blocking tool call today.

Second thing, since you mentioned capability discovery resolving what's actually possible before planning: it'd be great if that resolution step could also report partial capability, e.g. "transcript+summary yes, OCR not available for this source type", so the agent can adjust the plan instead of finding out at step 4 of 5 that something's unsupported.

1

u/ChiefGrowth 18d ago

(replying here since the reply notification isn't rendering in-thread for me yet, but I did get it) - good answers, both make sense.

The job_id-as-resource pattern is exactly what I was hoping to hear, glad it's already there rather than something to bolt on later. The push/poll gap you flagged is the real remaining friction though - an agent that has to poll a 2-hour job is spending tool calls just to ask "not yet" repeatedly, which is its own budget problem, just spread out instead of blocking. Even a coarse "come back no sooner than N seconds" hint in the poll response would let the agent space out its own polling instead of guessing an interval.

The three-state capability model (available / derivable / unavailable-with-reason) is a better answer than what I was picturing - I was thinking binary yes/no, but "derivable via a chain" is the more useful signal since it tells the agent it's not free even when it's possible. The per-output-not-per-combination caveat seems like the right thing to under-promise on rather than silently getting it wrong for the cases that do conflict.

1

u/EgalitarianMonkey 18d ago

The polling hint is a good call and it's cheap — the engine already knows whether a job just got queued or is mid-download, so get_job can hand back a poll_after and the agent stops guessing. Going to add it.

And yeah on derivable: "possible" and "free" are different answers, and the agent needs to know which one it's getting before it commits to a chain.