This commit is contained in:
Javis committed 2026-10-04 15:31:44 +02:00
1 parent 0246cbb6ba
commit 0ca4b4bf7a
89 files changed
+7042 -2480

No files matched your search

+28 -11
View File
@@ -4,20 +4,23 @@ MCP (Model Context Protocol) server that exposes an **Emby** media server to LLM
## Features
Tools:
Tools (all read-only except `play_item`; failures are returned as MCP tool errors):
- `ping` — verify connectivity / return Emby system info
- `list_items` — browse the media library with optional filtering
- `search_items` — search for items by name or metadata
- `get_item` — get detailed information about a specific item
- `list_items(parent_id?, item_type?, limit, sort_by)` — browse the media library
- `search_items(query, item_type?, limit)` — server-side search over the whole library
- `get_item(item_id)` — details incl. overview, genres, people, media sources
- `list_users` — list all users on the server
- `get_user` — get user details
- `list_sessions` — see who's currently connected / watching
- `list_sessions` — connected clients, incl. `supports_remote_control` and what is playing
- `system_info` — get server system information
- `playback_url` — get a direct stream URL for an item
- `playback_info` — get detailed playback/transcoding options
- `play_item` — send remote control commands to start playback
- `playback_url(item_id)` — direct stream URL (returned **without** credentials)
- `playback_info` — media sources, codecs, direct-play / transcoding support
- `play_item(item_id, device?, session_id?, play_command)` — play on a remote-controllable client
- `list_devices` — list registered devices
IDs are URL-encoded, so a crafted ID such as `123/Delete` or `../System/Restart`
cannot reach other Emby endpoints.
Resources:
- `emby://library` — snapshot of the entire media library as JSON
- `emby://users` — snapshot of all users as JSON
@@ -57,6 +60,13 @@ EMBY_SCHEME=https
EMBY_TIMEOUT=10.0
```
`EMBY_HOST` may also be `host:port` or a full base URL (`http://10.0.0.5:8096`).
`EMBY_SCHEME` defaults to `http` (Emby's own default on port 8096); set `https`
only for a TLS endpoint such as a reverse proxy. `EMBY_PORT` is ignored when the
host already contains a port. The `.env` file is read from the **current working
directory**, so when Javis launches the server, put the settings into the `env`
block of `mcp_servers.json` instead.
Get your API key from the Emby web dashboard: **Dashboard → Users → Manage Users → [your user] → Settings → API Keys**.
## Run
@@ -79,10 +89,10 @@ Add to your `mcp_servers.json`:
```json
"emby": {
"command": "d:\\Javis\\emby-mcp\\.venv\\Scripts\\python.exe",
"command": "F:\\Javis\\emby-mcp\\.venv\\Scripts\\python.exe",
"args": ["-m", "emby_mcp"],
"env": {
"EMBY_HOST": "emby.spaeth-bayern.de",
"EMBY_HOST": "http://10.0.0.5:8096",
"EMBY_API_KEY": "your-api-key-here"
},
"transport": "stdio",
@@ -107,7 +117,14 @@ list_items(item_type="Movie")
### Search for content
> "Find all episodes of Stranger Things"
```python
search_items("Stranger Things", limit=50)
search_items("Stranger Things", item_type="Episode", limit=50)
```
### Play on a TV
> "Play Stranger Things on the living-room TV"
```python
search_items("Stranger Things") # -> item id
play_item(item_id="...", device="TV") # needs an open, remote-controllable Emby app
```
### Check active sessions
+2 -1
View File
@@ -20,7 +20,8 @@ classifiers = [
"Operating System :: OS Independent",
]
dependencies = [
"mcp[cli]>=1.2",
# >=1.13: ToolAnnotations / transport security; 2.x removed mcp.server.fastmcp
"mcp[cli]>=1.13,<2",
"httpx>=0.27",
"pydantic>=2.7",
"pydantic-settings>=2.3",
+54 -34
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import logging
from typing import Any
from urllib.parse import quote
import httpx
@@ -11,6 +12,19 @@ from .config import Settings
log = logging.getLogger(__name__)
# Fields Emby only returns on request.
_LIST_FIELDS = "ProductionYear,ParentId,PremiereDate"
_DETAIL_FIELDS = (
"Overview,Genres,ProductionYear,PremiereDate,People,Studios,Taglines,"
"ProviderIds,ParentId,MediaSources,Path,CommunityRating,OfficialRating"
)
def _seg(value: Any) -> str:
"""One URL path segment: IDs come from the LLM and must not add path parts
(e.g. ``123/Delete`` or ``../System/Restart``)."""
return quote(str(value), safe="")
class EmbyClient:
"""Thin async wrapper around Emby's REST API."""
@@ -48,59 +62,65 @@ class EmbyClient:
return None
return None
async def _delete(self, path: str) -> Any:
r = await self._http.delete(path)
r.raise_for_status()
if r.content:
try:
return r.json()
except ValueError:
return None
return None
# ---- API: Items (media library) ---------------------------------------
async def list_items(
self,
parent_id: str | None = None,
include_related: bool = False,
recursive: bool = True,
sort_by: str | None = "Name",
sort_by: str | None = "SortName",
sort_order: str = "Ascending",
limit: int | None = None,
search_term: str | None = None,
item_types: str | None = None,
) -> dict[str, Any]:
"""List items from the media library."""
"""List or search items from the media library (server-side filtering)."""
params: dict[str, Any] = {
"IncludeRelated": str(include_related).lower(),
"Recursive": str(recursive).lower(),
"SortBy": sort_by,
"SortOrder": sort_order,
"Fields": _LIST_FIELDS,
}
if sort_by:
params["SortBy"] = sort_by
if parent_id:
params["ParentId"] = parent_id
if limit:
params["Limit"] = limit
if search_term:
params["SearchTerm"] = search_term
if item_types:
params["IncludeItemTypes"] = item_types
return await self._get("/Items", params=params)
async def get_item(self, item_id: str) -> dict[str, Any]:
"""Get details for a specific item."""
return await self._get(f"/Items/{item_id}")
"""Get details for a specific item (``GET /Items/{id}`` does not exist in Emby)."""
raw = await self._get(
"/Items", params={"Ids": item_id, "Recursive": "true", "Fields": _DETAIL_FIELDS}
)
items = raw.get("Items") if isinstance(raw, dict) else None
if not items:
raise LookupError(f"No Emby item with id {item_id!r}. Use search_items to find it.")
return items[0]
# ---- API: Users -------------------------------------------------------
async def list_users(self) -> dict[str, Any]:
async def list_users(self) -> list[dict[str, Any]]:
"""List all users on the server."""
return await self._get("/Users")
raw = await self._get("/Users/Query")
if isinstance(raw, dict):
return [u for u in raw.get("Items", []) if isinstance(u, dict)]
return raw if isinstance(raw, list) else []
async def get_user(self, user_id: str) -> dict[str, Any]:
"""Get details for a specific user."""
return await self._get(f"/Users/{user_id}")
return await self._get(f"/Users/{_seg(user_id)}")
# ---- API: Sessions ----------------------------------------------------
async def list_sessions(self) -> list[dict[str, Any]]:
"""List active sessions (connected devices/apps)."""
return await self._get("/Sessions")
raw = await self._get("/Sessions")
return raw if isinstance(raw, list) else []
async def get_system_info(self) -> dict[str, Any]:
"""Get server system info."""
@@ -108,27 +128,27 @@ class EmbyClient:
# ---- API: Playback ----------------------------------------------------
async def play_item(
self, item_id: str, user_id: str | None = None, autoplay: bool = True
async def play_on_session(
self, session_id: str, item_ids: list[str], play_command: str = "PlayNow"
) -> Any:
"""Start playback of an item."""
params: dict[str, Any] = {"UserId": user_id or "null", "Autostart": autoplay}
return await self._post(f"/Items/{item_id}/Play", params=params)
"""Remote-control a client session to play items."""
return await self._post(
f"/Sessions/{_seg(session_id)}/Playing",
params={"ItemIds": ",".join(item_ids), "PlayCommand": play_command},
)
async def get_playback_info(self, item_id: str, user_id: str | None = None) -> dict[str, Any]:
"""Get playback info for an item (transcoding options, etc.)."""
params = {}
if user_id:
params["UserId"] = user_id
return await self._get(f"/Items/{item_id}/PlaybackInfo", params=params)
async def get_playback_url(self, item_id: str) -> dict[str, Any]:
"""Get a stream URL for direct playback."""
return await self._get(f"/Items/{item_id}/StreamUrl")
return await self._get(f"/Items/{_seg(item_id)}/PlaybackInfo", params=params)
# ---- API: Device Info -------------------------------------------------
async def list_devices(self) -> list[dict[str, Any]]:
"""List registered devices."""
r = await self._get("/Devices")
return r if isinstance(r, list) else []
"""List registered devices (Emby wraps them in ``{"Items": [...]}``)."""
raw = await self._get("/Devices")
if isinstance(raw, dict):
raw = raw.get("Items", [])
return [d for d in raw if isinstance(d, dict)] if isinstance(raw, list) else []
+15 -6
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
import re
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -20,20 +22,27 @@ class Settings(BaseSettings):
extra="ignore",
)
host: str = Field(..., description="IP or hostname of the Emby server.")
host: str = Field(
...,
description="Emby host, 'host:port', or a full base URL like 'http://10.0.0.5:8096'.",
)
api_key: str = Field(..., description="Emby API key (from dashboard).")
scheme: str = Field("https", description="http or https.")
# Emby itself serves plain HTTP (8096) by default; HTTPS needs extra setup.
scheme: str = Field("http", description="http or https.")
port: int | None = Field(
None,
description="Optional TCP port.",
description="Optional TCP port (ignored when host already contains one).",
)
timeout: float = Field(10.0, description="HTTP timeout in seconds.")
@property
def base_url(self) -> str:
if self.port is None:
return f"{self.scheme}://{self.host}"
return f"{self.scheme}://{self.host}:{self.port}"
host = self.host.strip().rstrip("/")
if "://" in host:
return host
if self.port is None or re.search(r":\d+$", host):
return f"{self.scheme}://{host}"
return f"{self.scheme}://{host}:{self.port}"
def load_settings() -> Settings:
+168 -149
View File
@@ -7,15 +7,24 @@ from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import Any
from urllib.parse import quote
from mcp.server.fastmcp import FastMCP, Context
from pydantic import BaseModel, Field
from mcp.types import ToolAnnotations
from pydantic import BaseModel
from .client import EmbyClient
from .config import load_settings
log = logging.getLogger("emby_mcp")
_READ = ToolAnnotations(readOnlyHint=True, openWorldHint=False)
_PLAY = ToolAnnotations(readOnlyHint=False, destructiveHint=False, idempotentHint=False,
openWorldHint=False)
# Media types worth searching (without a filter Emby also returns People, Studios, ...).
_MEDIA_TYPES = "Movie,Series,Season,Episode,Audio,MusicAlbum,MusicArtist,MusicVideo,Video,BoxSet"
_TICKS_PER_MINUTE = 600_000_000
# ---- models --------------------------------------------------------------
@@ -24,16 +33,13 @@ class ItemSummary(BaseModel):
id: str
name: str
type: str | None = None
parent_id: str | None = Field(default=None, alias="ParentId")
series_name: str | None = Field(default=None, alias="SeriesName")
season_number: int | None = Field(default=None, alias="SeasonNumber")
episode_number: int | None = Field(default=None, alias="EpisodeNumber")
parent_id: str | None = None
series_name: str | None = None
season_number: int | None = None
episode_number: int | None = None
year: int | None = None
runtime_minutes: int | None = Field(default=None, alias="RunTimeNumeric")
size_mb: float | None = Field(default=None, alias="Size")
image_url: str | None = Field(default=None, alias="ImageTags")
model_config = {"populate_by_name": True}
runtime_minutes: int | None = None
size_mb: float | None = None
class UserSummary(BaseModel):
@@ -42,28 +48,17 @@ class UserSummary(BaseModel):
is_admin: bool = False
has_authorization: bool = True
model_config = {"populate_by_name": True}
class SessionInfo(BaseModel):
user_id: str | None = Field(default=None, alias="UserId")
user_name: str | None = Field(default=None, alias="UserName")
client_name: str | None = Field(default=None, alias="ClientName")
device_name: str | None = Field(default=None, alias="DeviceName")
app_id: str | None = Field(default=None, alias="AppId")
device_type: str | None = Field(default=None, alias="DeviceType")
model_config = {"populate_by_name": True}
class PlaybackInfo(BaseModel):
playable_url: str | None = None
transcoding_url: str | None = None
can_transcode: bool = False
audio_codec: str | None = None
video_codec: str | None = None
model_config = {"populate_by_name": True}
id: str | None = None
user_id: str | None = None
user_name: str | None = None
client_name: str | None = None
device_name: str | None = None
app_id: str | None = None
device_type: str | None = None
supports_remote_control: bool = False
now_playing: str | None = None
# ---- lifespan ------------------------------------------------------------
@@ -99,11 +94,12 @@ mcp = FastMCP(
"AVAILABLE TOOLS SUMMARY:\n"
" Library: list_items, get_item, search_items\n"
" Users: list_users, get_user\n"
" Sessions:list_sessions, system_info\n"
" Sessions:list_sessions, system_info, list_devices\n"
" Playback:play_item, playback_url, playback_info\n\n"
"COMMON WORKFLOWS:\n"
" 'What movies do I have?' → list_items(type='Movie')\n"
" 'Play Stranger Things' → search_items('Stranger Things') → play_item(id)\n"
" 'What movies do I have?' → list_items(item_type='Movie')\n"
" 'Play Stranger Things on the TV' → search_items('Stranger Things') → "
"play_item(item_id, device='TV')\n"
" 'Who is connected?' → list_sessions()\n"
),
lifespan=lifespan,
@@ -120,23 +116,40 @@ def _emby(ctx: Context) -> EmbyClient:
def _item_to_summary(item: dict[str, Any]) -> ItemSummary:
"""Convert an Emby item dict to a summary."""
ticks = item.get("RunTimeTicks")
size = item.get("Size")
return ItemSummary(
id=str(item.get("Id", "")),
name=item.get("Name"),
name=str(item.get("Name") or ""),
type=item.get("Type"),
parent_id=item.get("ParentId"),
series_name=item.get("SeriesName"),
season_number=item.get("SeasonNumber"),
episode_number=item.get("EpisodeNumber"),
year=item.get("ProductionYear") or item.get("Year"),
runtime_minutes=item.get("RunTimeNumeric"),
size_mb=item.get("Size") / (1024 * 1024) if item.get("Size") else None,
season_number=item.get("ParentIndexNumber") if item.get("Type") == "Episode" else None,
episode_number=item.get("IndexNumber") if item.get("Type") == "Episode" else None,
year=item.get("ProductionYear"),
runtime_minutes=round(ticks / _TICKS_PER_MINUTE) if isinstance(ticks, (int, float)) else None,
size_mb=round(size / (1024 * 1024), 1) if isinstance(size, (int, float)) else None,
)
def _session_to_info(s: dict[str, Any]) -> SessionInfo:
playing = s.get("NowPlayingItem") or {}
return SessionInfo(
id=s.get("Id"),
user_id=s.get("UserId"),
user_name=s.get("UserName"),
client_name=s.get("Client") or s.get("ClientName"),
device_name=s.get("DeviceName"),
app_id=s.get("AppId"),
device_type=s.get("DeviceType"),
supports_remote_control=bool(s.get("SupportsRemoteControl")),
now_playing=playing.get("Name") if isinstance(playing, dict) else None,
)
# ---- tools ---------------------------------------------------------------
@mcp.tool()
@mcp.tool(annotations=_READ)
async def ping(ctx: Context) -> dict[str, Any]:
"""Verify connectivity to the Emby server and return system info."""
emby = _emby(ctx)
@@ -147,89 +160,87 @@ async def ping(ctx: Context) -> dict[str, Any]:
"server_name": info.get("ServerName"),
"version": info.get("Version"),
"id": info.get("Id"),
"date_created": info.get("dateCreated"),
}
except Exception as exc:
return {"ok": False, "error": str(exc)}
@mcp.tool()
@mcp.tool(annotations=_READ)
async def list_items(
ctx: Context,
parent_id: str | None = None,
item_type: str | None = None,
limit: int = 50,
sort_by: str | None = "Name",
sort_by: str | None = "SortName",
) -> list[ItemSummary]:
"""List items from the Emby media library.
Args:
parent_id: Parent folder ID to browse within (optional).
limit: Maximum number of items to return (default 50).
sort_by: Sort field name, e.g. 'Name', 'DateCreated', 'Rating' (default 'Name').
item_type: Emby item type(s), comma-separated, e.g. 'Movie', 'Series',
'Episode', 'MusicAlbum', 'Audio' (optional).
limit: Maximum number of items to return (1-200, default 50).
sort_by: Sort field, e.g. 'SortName', 'DateCreated', 'CommunityRating',
'ProductionYear' (default 'SortName').
"""
emby = _emby(ctx)
raw = await emby.list_items(parent_id=parent_id, limit=limit, sort_by=sort_by)
raw = await emby.list_items(parent_id=parent_id, limit=max(1, min(limit, 200)),
sort_by=sort_by, item_types=item_type)
items = raw.get("Items", []) if isinstance(raw, dict) else []
return [_item_to_summary(i) for i in items if isinstance(i, dict)]
@mcp.tool()
async def search_items(ctx: Context, query: str, limit: int = 20) -> list[ItemSummary]:
"""Search for media items by name.
Note: Currently performs a client-side filter on item names. For full-text search,
use the Emby web interface or provide specific item types and parent folders.
@mcp.tool(annotations=_READ)
async def search_items(
ctx: Context, query: str, item_type: str | None = None, limit: int = 20
) -> list[ItemSummary]:
"""Search the whole media library by name (server-side search).
Args:
query: Text to search for in item names.
limit: Maximum number of results (default 20).
query: Text to search for (title, series, album, artist ...).
item_type: Optional Emby item type(s), comma-separated, e.g. 'Movie' or
'Series,Episode'. Defaults to all playable media types.
limit: Maximum number of results (1-100, default 20).
"""
if not query.strip():
raise ValueError("query must not be empty")
emby = _emby(ctx)
# Fetch a larger batch to filter on client side (search not natively supported by client)
raw = await emby.list_items(limit=limit * 3)
raw = await emby.list_items(search_term=query.strip(), item_types=item_type or _MEDIA_TYPES,
limit=max(1, min(limit, 100)), sort_by=None)
items = raw.get("Items", []) if isinstance(raw, dict) else []
# Simple client-side filter on Name field
query_lower = query.lower()
filtered = [
i for i in items
if isinstance(i, dict) and query_lower in (i.get("Name") or "").lower()
][:limit]
return [_item_to_summary(i) for i in filtered]
return [_item_to_summary(i) for i in items if isinstance(i, dict)]
@mcp.tool()
@mcp.tool(annotations=_READ)
async def get_item(ctx: Context, item_id: str) -> dict[str, Any]:
"""Get detailed information about a specific media item.
Args:
item_id: The Emby item ID (UUID format).
item_id: The Emby item ID (from search_items / list_items).
"""
emby = _emby(ctx)
return await emby.get_item(item_id)
@mcp.tool()
@mcp.tool(annotations=_READ)
async def list_users(ctx: Context) -> list[UserSummary]:
"""List all users on the Emby server."""
emby = _emby(ctx)
raw = await emby.list_users()
users = raw if isinstance(raw, list) else []
users = await emby.list_users()
return [
UserSummary(
id=str(u.get("Id", "")),
name=u.get("Name"),
is_admin=u.get("IsAdmin", False),
name=str(u.get("Name") or ""),
is_admin=bool((u.get("Policy") or {}).get("IsAdministrator", u.get("IsAdmin", False))),
has_authorization=u.get("HasAuthorization", True),
)
for u in users if isinstance(u, dict)
for u in users
]
@mcp.tool()
@mcp.tool(annotations=_READ)
async def get_user(ctx: Context, user_id: str) -> dict[str, Any]:
"""Get detailed information about a specific user.
@@ -240,124 +251,132 @@ async def get_user(ctx: Context, user_id: str) -> dict[str, Any]:
return await emby.get_user(user_id)
@mcp.tool()
@mcp.tool(annotations=_READ)
async def list_sessions(ctx: Context) -> list[SessionInfo]:
"""List all active sessions (connected devices/apps)."""
"""List active sessions (connected devices/apps).
Sessions with `supports_remote_control` can be targeted by `play_item`.
"""
emby = _emby(ctx)
raw = await emby.list_sessions()
return [
SessionInfo(
user_id=s.get("UserId"),
user_name=s.get("UserName"),
client_name=s.get("ClientName"),
device_name=s.get("DeviceName"),
app_id=s.get("AppId"),
device_type=s.get("DeviceType"),
)
for s in raw if isinstance(s, dict)
]
return [_session_to_info(s) for s in await emby.list_sessions() if isinstance(s, dict)]
@mcp.tool()
@mcp.tool(annotations=_READ)
async def system_info(ctx: Context) -> dict[str, Any]:
"""Get Emby server system information."""
emby = _emby(ctx)
return await emby.get_system_info()
@mcp.tool()
async def playback_url(
ctx: Context, item_id: str, user_id: str | None = None, format: str = "mp4"
) -> dict[str, Any]:
"""Get a direct stream URL for playing media.
@mcp.tool(annotations=_READ)
async def playback_url(ctx: Context, item_id: str) -> dict[str, Any]:
"""Get the direct (static) stream URL of a media item.
The URL is returned WITHOUT credentials: a player must authenticate with
an `X-Emby-Token` header (or `api_key` parameter) of its own.
Args:
item_id: The Emby item ID to play.
user_id: Optional user ID for access control.
format: Output format (default mp4).
Note: This returns a temporary URL that may expire. Use playback_info first
if you need transcoding options.
item_id: The Emby item ID.
"""
emby = _emby(ctx)
try:
result = await emby.get_playback_url(item_id)
return {"url": result, "item_id": item_id}
except Exception as exc:
return {"error": str(exc), "item_id": item_id}
item = await emby.get_item(item_id)
kind = "Audio" if item.get("MediaType") == "Audio" else "Videos"
url = f"{emby.settings.base_url}/{kind}/{quote(str(item.get('Id', item_id)), safe='')}/stream?static=true"
return {"item_id": item_id, "name": item.get("Name"), "url": url,
"auth": "requires X-Emby-Token header or api_key query parameter"}
@mcp.tool()
@mcp.tool(annotations=_READ)
async def playback_info(
ctx: Context, item_id: str, user_id: str | None = None
) -> dict[str, Any]:
"""Get detailed playback information for an item.
Returns transcoding options, compatible codecs, and stream URLs
for direct playback or transcoding.
Returns the media sources with container, codecs and whether direct play
or transcoding is supported.
Args:
item_id: The Emby item ID.
user_id: Optional user ID for access control.
"""
emby = _emby(ctx)
try:
info = await emby.get_playback_info(item_id, user_id)
# Extract relevant playback information
result: dict[str, Any] = {
"item_id": item_id,
"can_transcode": info.get("CanTranscode", False),
"media_sources": [],
}
media_sources = info.get("MediaSources", [])
for source in media_sources:
if isinstance(source, dict):
result["media_sources"].append({
"id": source.get("Id"),
"protocol": source.get("Protocol"),
"container": source.get("Container"),
"video_codec": source.get("VideoCodec"),
"audio_codec": source.get("AudioCodec"),
"direct_path": source.get("DirectPath"),
})
return result
except Exception as exc:
return {"error": str(exc), "item_id": item_id}
info = await emby.get_playback_info(item_id, user_id)
sources = [s for s in info.get("MediaSources", []) if isinstance(s, dict)]
return {
"item_id": item_id,
"can_transcode": any(s.get("SupportsTranscoding") for s in sources),
"media_sources": [
{
"id": s.get("Id"),
"protocol": s.get("Protocol"),
"container": s.get("Container"),
"supports_direct_play": s.get("SupportsDirectPlay"),
"supports_transcoding": s.get("SupportsTranscoding"),
"streams": [
{"type": m.get("Type"), "codec": m.get("Codec"), "language": m.get("Language")}
for m in s.get("MediaStreams", []) if isinstance(m, dict)
],
}
for s in sources
],
}
@mcp.tool()
@mcp.tool(annotations=_PLAY)
async def play_item(
ctx: Context,
item_id: str,
user_id: str | None = None,
autoplay: bool = True,
device: str | None = None,
session_id: str | None = None,
play_command: str = "PlayNow",
) -> dict[str, Any]:
"""Start playback of a media item.
"""Play a media item on a connected Emby client (remote control).
The target is a session that supports remote control: pass `session_id`
(from list_sessions) or a `device` name substring (e.g. 'TV'). If omitted
and exactly one controllable session exists, that one is used.
Args:
item_id: The Emby item ID to play.
user_id: Optional user ID for access control.
autoplay: Whether to start playback immediately (default True).
Note: This sends a remote control command to connected sessions.
device: Device/client name substring to pick the session.
session_id: Exact session ID (overrides device).
play_command: 'PlayNow' (default), 'PlayNext' or 'PlayLast'.
"""
if play_command not in ("PlayNow", "PlayNext", "PlayLast"):
raise ValueError("play_command must be PlayNow, PlayNext or PlayLast")
emby = _emby(ctx)
try:
result = await emby.play_item(item_id, user_id, autoplay)
return {"ok": True, "item_id": item_id, "result": result}
except Exception as exc:
return {"ok": False, "error": str(exc), "item_id": item_id}
sessions = [s for s in await emby.list_sessions() if isinstance(s, dict)]
controllable = [s for s in sessions if s.get("SupportsRemoteControl")]
if session_id:
targets = [s for s in controllable if s.get("Id") == session_id]
elif device:
d = device.strip().lower()
targets = [s for s in controllable
if d in str(s.get("DeviceName", "")).lower() or d in str(s.get("Client", "")).lower()]
else:
targets = controllable
if len(targets) != 1:
names = [f"{s.get('DeviceName')} ({s.get('Client')}, id={s.get('Id')})" for s in controllable]
reason = "No matching" if not targets else "More than one matching"
raise ValueError(
f"{reason} remote-controllable session. Pass device or session_id. "
f"Controllable sessions: {names or 'none (open an Emby app first)'}"
)
target = targets[0]
await emby.play_on_session(str(target["Id"]), [item_id], play_command)
return {"ok": True, "item_id": item_id, "session_id": target.get("Id"),
"device": target.get("DeviceName"), "command": play_command}
@mcp.tool()
@mcp.tool(annotations=_READ)
async def list_devices(ctx: Context) -> list[dict[str, Any]]:
"""List registered devices connected to the Emby server."""
emby = _emby(ctx)
return await emby.list_devices()
return [
{"id": d.get("Id"), "name": d.get("Name"), "app": d.get("AppName"),
"last_user": d.get("LastUserName"), "last_active": d.get("DateLastActivity")}
for d in await emby.list_devices()
]
# ---- resources -----------------------------------------------------------