An MCP server that gives Claude (or any MCP-compatible AI) direct, natural-language access to your Jellyfin media library.
Ask Claude to find something to watch tonight and it will analyze your entire watch history, cross-reference Letterboxd ratings you've imported, consult TMDB for curated recommendations, and return a ranked list of unwatched films tailored to your taste — all in a single conversation turn.
"What should I watch tonight? Something intense, under 2 hours."
"Find me movies similar to Parasite that I haven't seen yet."
"Show me every A24 film in my library."
"What Coen Brothers movies do I still need to watch?"
"Find movies with both Cate Blanchett and Brad Pitt."
"What are my watch stats? How many hours have I logged?"
"Pause whatever's playing on the living room TV."
All of these work out of the box — no custom prompting required.
Claude Code / Claude Desktop
│ MCP (stdio)
▼
┌─────────────────────────────────────────────┐
│ jellyfin-mcp │
│ │
│ tools.py ← @mcp.tool() definitions │
│ client.py ← async httpx Jellyfin API │
│ models.py ← Pydantic v2 parsing │
│ config.py ← pydantic-settings env │
│ db.py ← asyncpg Postgres pool │
└──────┬──────────────┬────────────────┬──────┘
│ │ │
Jellyfin API TMDB API Postgres DB
(required) (optional) (optional — for
Letterboxd sync)
Three services, two optional. The server degrades gracefully — TMDB enhances similarity search; Postgres enables Letterboxd rating import. Both are opt-in.
| Tool | Description |
|---|---|
search_media |
Full-text search across movies, shows, episodes, music |
get_item_details |
Full metadata, cast, and director for a specific item |
get_recently_added |
What's been added to the library recently |
get_continue_watching |
In-progress items with resume position |
find_unwatched_by_director |
Composite tool — filmography lookup + watch-state filter |
find_watched_by_director |
Same, but shows what you've already seen |
search_by_cast |
Movies starring any combination of actors, ranked by cast overlap |
search_by_studio |
Browse by production studio or distributor (A24, Ghibli, Neon, etc.) |
search_by_genre |
Genre browse with optional unwatched filter |
find_similar |
Recommendation engine — see below |
get_taste_profile |
Weighted genre/tag affinities derived from your watch history |
recommend_for_tonight |
Personalized picks, optionally filtered by mood and runtime |
get_watch_stats |
Movies watched vs. unwatched, top genres, hours logged |
get_favorites |
Everything you've hearted in Jellyfin |
mark_as_watched |
Mark one or more items watched/unwatched |
mark_as_favorite |
Add or remove from favorites |
sync_letterboxd_matches |
Link Letterboxd CSV imports to Jellyfin library items |
get_active_sessions |
Who's currently watching and what |
control_playback |
Pause, play, stop, or seek any active session |
check_server_status |
Connectivity check and server info |
find_similar and recommend_for_tonight use a multi-signal scoring pipeline:
1. IDF-weighted genre matching
Genres are weighted by how rare they are in your library:
weight(genre) = log(total_movies / movies_with_genre)
"Drama" appears in 60% of films → low weight. "Animation" appears in 5% → high weight. This means a niche genre overlap scores much more than a broad one, which is how you get genuinely similar results rather than everything being Drama.
2. Thematic keyword matching
Keywords extracted from overviews and Jellyfin tags are matched against the reference film's description. This surfaces films with shared themes (grief, isolation, corporate satire) even when genres don't overlap perfectly.
3. Exclusive genre penalties
Tags like superhero, gladiator, or medieval that signal a specific genre subtype are penalized when the reference film doesn't have them. This prevents a quiet character drama from surfacing action blockbusters just because both are tagged Drama.
4. TMDB cross-reference (optional)
When TMDB is configured, the engine fetches curated recommendations and similar films from TMDB and cross-references them against your library. Position-weighted bonus: the #1 TMDB recommendation gets +10 points, decaying linearly. Falls back to Jellyfin-only scoring if TMDB is unavailable.
5. Taste profile (for recommend_for_tonight)
Weighted genre and tag affinity scores are built from your full watch history:
| Source | Weight |
|---|---|
| Letterboxd ⭐⭐⭐⭐⭐ (5 stars) | +10 |
| Letterboxd ⭐⭐⭐⭐ (4 stars) | +5 |
| Letterboxd ⭐⭐ (2 stars) | 0 |
| Letterboxd ⭐ (1 star) | −2 |
| Jellyfin favorite | +3 |
| Jellyfin watched (unrated) | +1 |
Negative weights from low-rated films actively push down genres and themes you don't enjoy. recommend_for_tonight combines this profile with optional mood filtering (10 moods: light, intense, scary, thoughtful, etc.) and filters for runtime cap and minimum community rating.
- Python 3.11+
- uv (recommended) or pip
- A running Jellyfin instance
git clone https://github.com/yourname/jellyfin-mcp
cd jellyfin-mcp
uv syncCopy the example env file and fill in your values:
cp .env.example .env# Required
JELLYFIN_URL=http://192.168.1.100:8096
JELLYFIN_API_KEY=your_api_key_here
JELLYFIN_USER_ID=your_user_id_here
# Optional — enables TMDB-enhanced similarity search
TMDB_READ_ACCESS_TOKEN=your_tmdb_token
# Optional — enables Letterboxd rating import
DB_URL=postgresql://user:pass@localhost:5432/homelabGet your API key: Jellyfin Dashboard → API Keys → + button.
Get your user ID: Dashboard → Users → click your user → read userId from the URL.
Get a TMDB token: themoviedb.org/settings/api — free account, no credit card.
Tests use respx to mock httpx at the transport layer, so the full tool logic runs in CI without a real server.
uv run pytestEdit ~/.claude.json:
{
"mcpServers": {
"jellyfin": {
"command": "uv",
"args": ["run", "--directory", "/path/to/jellyfin-mcp", "jellyfin-mcp"],
"env": {
"JELLYFIN_URL": "http://192.168.1.100:8096",
"JELLYFIN_API_KEY": "your_api_key_here",
"JELLYFIN_USER_ID": "your_user_id_here"
}
}
}
}Restart Claude Code. The tools appear automatically in the MCP panel.
Edit ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or %APPDATA%\Claude\claude_desktop_config.json (Windows) — same JSON structure as above.
If you want the recommendation engine to use your actual star ratings instead of just watch history:
- Export your data from letterboxd.com/settings/data
- Set up Postgres and run the import script:
uv run python scripts/import_letterboxd.py /path/to/letterboxd-export/watched.csv- Link your Letterboxd films to Jellyfin items. Either ask Claude directly:
"Sync my Letterboxd matches"
Or run iteratively until all rows are matched:
# Dry run first — see what would be matched
uv run python -c "
import asyncio
from jellyfin_mcp.tools import sync_letterboxd_matches
print(asyncio.run(sync_letterboxd_matches(dry_run=True)))
"The sync tool matches by normalized title + year (±1 tolerance), handling punctuation and article differences automatically. Unresolved titles (translated names, alternate spellings) are returned in the unresolved list for manual review.
src/jellyfin_mcp/
├── server.py # Entrypoint — mcp.run(transport="stdio")
├── tools.py # All MCP tool definitions (@mcp.tool())
├── client.py # Async httpx Jellyfin + TMDB clients
├── models.py # Pydantic v2 response models
├── config.py # pydantic-settings env config (3 settings classes)
└── db.py # asyncpg connection pool + rating helpers
scripts/
├── import_letterboxd.py # One-shot Letterboxd CSV → Postgres import
└── index_subtitles.py # Subtitle embedding pipeline (see Extending)
tests/
└── test_tools.py # respx-mocked async tests for every tool
-
FastMCP + type hints → auto JSON schema —
@mcp.tool()reads Python type annotations and docstrings to generate the MCP tool schema. No separate schema files or manual registration. -
Composable async tools —
find_unwatched_by_directormakes three chained API calls (search by person, fetch watch state, filter) in a single tool invocation, returning a clean result.search_by_castruns N parallel searches and deduplicates by overlap count. -
Graceful degradation — TMDB and Postgres integrations are fully optional. The server detects whether they're configured at startup and silently omits those code paths. TMDB failures during similarity search are caught and ignored; the tool returns Jellyfin-only results.
-
No crashes exposed to Claude — every tool catches its error type and returns a descriptive string. Claude can explain the error to the user and suggest a fix rather than receiving an opaque exception.
-
Testable without infrastructure —
respxintercepts httpx at the transport level. Tests exercise the full tool → client → model → response pipeline with realistic fixture data, no mocking of internal functions. -
IDF genre scoring — avoids the naive "count matching genres" approach by weighting genres by their inverse document frequency in the library. This is the same concept as TF-IDF in information retrieval, applied to a local media collection.
Ideas for what to build next:
- Scene search (in progress) —
index_subtitles.pychunks and embeds subtitle tracks into pgvector usingall-MiniLM-L6-v2. The intent: ask Claude "pull up the scene where Jules recites the Bible verse" and get a deeplink to that exact timestamp. The pipeline works; the MCP tool is being hardened. - TV series support — episode-level watch state, "find the next unwatched episode" tool
- Music tools — now-playing for audio sessions, playlist management
- Multi-user — accept
user_idas a tool parameter to support households with multiple Jellyfin accounts - SSE transport — run as a persistent HTTP service instead of a per-conversation subprocess
- Webhook listener — emit MCP notifications when new content is added to the library
MIT