Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
5cb43da
feat(fastmcp_server): add fastmcp_server provider v0.1.0
github-actions[bot] May 9, 2026
73cad05
feat(fastmcp_server): sync provider from ma-provider-mcp v0.2.0
github-actions[bot] May 9, 2026
072fe08
feat(fastmcp_server): sync provider from ma-provider-mcp v0.2.0
github-actions[bot] May 9, 2026
5cb289a
feat(fastmcp_server): sync provider from ma-provider-mcp v0.2.1
github-actions[bot] May 9, 2026
0f092b0
feat(fastmcp_server): sync provider from ma-provider-mcp v0.2.2
github-actions[bot] May 9, 2026
d1979fc
feat(fastmcp_server): sync provider from ma-provider-mcp v0.2.3
github-actions[bot] May 9, 2026
9217d1d
feat(fastmcp_server): sync provider from ma-provider-mcp v0.2.4
github-actions[bot] May 9, 2026
a9124f3
feat(fastmcp_server): sync provider from ma-provider-mcp v0.3.0
github-actions[bot] May 10, 2026
3048a75
feat(fastmcp_server): sync provider from ma-provider-mcp v0.3.1
github-actions[bot] May 10, 2026
68c17e1
feat(fastmcp_server): sync provider from ma-provider-mcp v0.3.1
github-actions[bot] May 10, 2026
264b08d
feat(fastmcp_server): sync provider from ma-provider-mcp v0.3.2
github-actions[bot] May 10, 2026
1f46ce5
feat(fastmcp_server): sync provider from ma-provider-mcp v0.3.3
github-actions[bot] May 10, 2026
8c593c8
feat(fastmcp_server): sync provider from ma-provider-mcp v0.3.4
github-actions[bot] May 10, 2026
8597c96
feat(fastmcp_server): sync provider from ma-provider-mcp v0.3.5
github-actions[bot] May 10, 2026
f03217f
Merge branch 'dev' into upstream/fastmcp_server
trudenboy May 12, 2026
f3961c1
feat(fastmcp_server): sync provider from ma-provider-mcp v0.3.6
github-actions[bot] May 12, 2026
b5fc300
feat(fastmcp_server): sync provider from ma-provider-mcp v0.3.7
github-actions[bot] May 12, 2026
20cf9ea
Merge branch 'dev' into upstream/fastmcp_server
trudenboy May 12, 2026
f6d6128
feat(fastmcp_server): sync provider from ma-provider-mcp v0.3.8
github-actions[bot] May 12, 2026
5c5c336
feat(fastmcp_server): sync provider from ma-provider-mcp v0.3.9
github-actions[bot] May 12, 2026
e15bbfe
feat(fastmcp_server): sync provider from ma-provider-mcp v0.3.10
github-actions[bot] May 12, 2026
1fd0b6c
Merge branch 'dev' into upstream/fastmcp_server
trudenboy May 12, 2026
b4f0db8
feat(fastmcp_server): sync provider from ma-provider-mcp v0.3.11
github-actions[bot] May 12, 2026
1a7da96
feat(fastmcp_server): sync provider from ma-provider-mcp v0.3.12
github-actions[bot] May 13, 2026
5f99200
feat(fastmcp_server): sync provider from ma-provider-mcp v0.3.13
github-actions[bot] May 13, 2026
844f1a8
Merge branch 'dev' into upstream/fastmcp_server
trudenboy May 13, 2026
f9eebe2
feat(fastmcp_server): sync provider from ma-provider-mcp v0.3.15
github-actions[bot] May 13, 2026
67ff1d1
feat(fastmcp_server): sync provider from ma-provider-mcp v0.3.16
github-actions[bot] May 13, 2026
0e1366b
Merge branch 'dev' into upstream/fastmcp_server
trudenboy May 13, 2026
c9a25b1
Merge branch 'dev' into upstream/fastmcp_server
trudenboy May 13, 2026
44e381e
feat(fastmcp_server): sync provider from ma-provider-mcp v0.3.17
github-actions[bot] May 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions music_assistant/providers/fastmcp_server/VERSION
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
0.3.17
Comment thread
trudenboy marked this conversation as resolved.
170 changes: 170 additions & 0 deletions music_assistant/providers/fastmcp_server/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
"""
MCP Server Plugin Provider for Music Assistant.

Exposes Music Assistant's library, queue, playback, players, and metadata
controllers as a Model Context Protocol server, accessible to Claude Code,
Codex, and other MCP-aware LLM clients.

The runtime is built on PrefectHQ FastMCP v3 and mounted into MA's existing
aiohttp webserver under ``/mcp/v1`` via an ASGI bridge — no second uvicorn,
no extra port, no changes to MA core.
"""

from __future__ import annotations

import logging
from typing import TYPE_CHECKING, Any

LOGGER = logging.getLogger(__name__)
Comment thread
trudenboy marked this conversation as resolved.
Comment thread
trudenboy marked this conversation as resolved.
Comment thread
trudenboy marked this conversation as resolved.

if TYPE_CHECKING:
from music_assistant_models.config_entries import (
ConfigEntry,
ConfigValueType,
ProviderConfig,
)
from music_assistant_models.provider import ProviderManifest

from music_assistant.mass import MusicAssistant
from music_assistant.models import ProviderInstanceType


async def get_config_entries(
mass: MusicAssistant,
instance_id: str | None = None, # noqa: ARG001
action: str | None = None,
values: dict[str, ConfigValueType] | None = None,
) -> tuple[ConfigEntry, ...]:
"""Return Config entries to setup this provider.

When ``action == "open_connect"`` is dispatched, mint a bootstrap token
bound to the calling user (when available) and signal MA's frontend to
open the Connect Wizard URL — the entries themselves are returned
unchanged so the settings panel re-renders cleanly.
"""
from .config import build_config_entries # noqa: PLC0415

if action == "open_connect":
await _dispatch_open_connect(mass, values or {})

return build_config_entries(mass, values or {})


def _sanitize_external_base_url(value: str | None) -> str | None:
"""Return ``value`` if it is a plausible ``http(s)://`` base URL, else ``None``.

Defends against an admin pasting (or a misbehaving proxy injecting) a
scheme-less or ``javascript:`` URL into the Connect Wizard link, which
the MA frontend would feed straight to ``window.open``.
"""
if not value:
return None
candidate = value.strip()
if not candidate.lower().startswith(("http://", "https://")):
LOGGER.warning(
"Connect Wizard: ignoring external base URL with unsupported scheme: %r",
candidate,
)
return None
return candidate


def _detect_external_base_url(mass: MusicAssistant, current_user: Any) -> str | None:
"""Return the external base URL for the current user's active WS client.

MA's :class:`WebsocketClientHandler` stores a per-connection ``base_url``
derived from ``X-Forwarded-Host`` + ``X-Ingress-Path`` — exactly the
prefix the Connect Wizard needs so ``window.open`` produces a working
URL under Home Assistant add-on ingress. We pick the client whose
authenticated user matches the invoker of the action.

Returns ``None`` when nothing matches (e.g. action invoked outside the
WS server, or no forward headers were captured).
"""
if current_user is None:
return None
try:
webserver = getattr(mass, "webserver", None)
clients = getattr(webserver, "clients", None) or ()
except Exception:
return None

def _user_id(user: Any) -> Any:
return getattr(user, "user_id", None) or getattr(user, "username", None)

target = _user_id(current_user)
for client in clients:
client_base = getattr(client, "base_url", None)
if not client_base:
continue
client_user = getattr(client, "_authenticated_user", None)
if client_user is None:
continue
if _user_id(client_user) == target:
return str(client_base)
return None


async def _dispatch_open_connect(
mass: MusicAssistant,
values: dict[str, ConfigValueType],
) -> None:
"""Mint a wizard bootstrap and signal the wizard URL to the frontend.

The MA frontend's ``EditProvider`` view subscribes to ``AUTH_SESSION``
events and ignores anything whose ``object_id`` does not match the
``session_id`` it injected into ``values``. We must echo that same id
back as the event's ``object_id`` so the browser tab actually opens.

URL resolution order: (1) auto-detect from the active WS client's
ingress-aware ``base_url``; (2) explicit ``connect_external_url`` config
override; (3) path-only fallback resolved against the browser's origin.
"""
from .connect import handle_open_connect_action # noqa: PLC0415
from .constants import ( # noqa: PLC0415
CONF_CONNECT_EXTERNAL_URL,
CONF_MOUNT_PATH,
DEFAULT_MOUNT_PATH,
)

mount_path = str(values.get(CONF_MOUNT_PATH) or DEFAULT_MOUNT_PATH)
session_id = str(values.get("session_id") or "")

current_user: object | None = None
try:
from music_assistant.controllers.webserver.helpers.auth_middleware import ( # noqa: PLC0415
get_current_user,
)

current_user = get_current_user()
except Exception:
LOGGER.debug("Connect Wizard: get_current_user lookup failed", exc_info=True)
current_user = None

external_base_url = _sanitize_external_base_url(_detect_external_base_url(mass, current_user))
if not external_base_url:
external_base_url = _sanitize_external_base_url(
str(values.get(CONF_CONNECT_EXTERNAL_URL) or "")
)

try:
await handle_open_connect_action(
mass,
current_user=current_user,
mount_path=mount_path,
session_id=session_id or None,
external_base_url=external_base_url,
)
except Exception:
LOGGER.exception("Connect Wizard: open_connect action failed")


async def setup(
mass: MusicAssistant,
manifest: ProviderManifest,
config: ProviderConfig,
) -> ProviderInstanceType:
"""Initialize provider instance with given configuration."""
from .provider import MCPServerProvider # noqa: PLC0415

return MCPServerProvider(mass, manifest, config)
162 changes: 162 additions & 0 deletions music_assistant/providers/fastmcp_server/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
"""Token verifier delegating to MA's existing authentication subsystem.

The plugin does not implement JWT decoding or scope checks of its own — this
is intentional. ``mass.webserver.auth.authenticate_with_token`` already handles
both JWT (PR #2891) and legacy hash tokens, and updates the sliding-window
expiry on every successful call. Wiring our own JWT decode here would only
duplicate the work and create two sources of truth.

Passing ``base_url`` upstream to :class:`fastmcp.server.auth.TokenVerifier`
lets FastMCP's built-in ``RequireAuthMiddleware`` populate the
``resource_metadata="…"`` parameter in ``WWW-Authenticate`` headers on
401 responses (RFC 9728 / MCP authorization spec MUST).
"""

from __future__ import annotations

import base64
import binascii
import json
import logging
from typing import TYPE_CHECKING

from fastmcp.server.auth import TokenVerifier
from fastmcp.server.auth.auth import AccessToken

if TYPE_CHECKING:
from music_assistant.mass import MusicAssistant

LOGGER = logging.getLogger(__name__)


def _extract_jwt_audience(token: str) -> str | list[str] | None:
"""Best-effort decode of a JWT payload to extract the ``aud`` claim.

Returns ``None`` for non-JWT tokens (legacy MA hash tokens), malformed
payloads, or JWTs without an ``aud`` claim. Does **not** verify the
signature — that's MA's responsibility via ``authenticate_with_token``;
we only read the audience claim to compare against this MCP server's
canonical URI.
"""
parts = token.split(".")
if len(parts) != 3:
return None
payload_segment = parts[1]
# Restore base64url padding stripped by JWT spec.
pad = "=" * (-len(payload_segment) % 4)
try:
raw = base64.urlsafe_b64decode(payload_segment + pad)
claims = json.loads(raw)
except (binascii.Error, ValueError, UnicodeDecodeError):
return None
aud = claims.get("aud") if isinstance(claims, dict) else None
if isinstance(aud, (str, list)) or aud is None:
return aud
return None


def _audience_matches(aud: str | list[str] | None, expected: str) -> bool:
"""RFC 8707: token is bound to ``expected`` if its ``aud`` is or contains it."""
if aud is None:
return False
if isinstance(aud, str):
return aud == expected
return expected in aud


class MASTokenVerifier(TokenVerifier):
"""Verify Bearer tokens against ``mass.webserver.auth``."""

def __init__(
self,
mass: MusicAssistant,
*,
base_url: str | None = None,
public_resource_uri: str | None = None,
enforce_audience: bool = False,
) -> None:
"""Bind the verifier to a MusicAssistant instance.

:param mass: MusicAssistant instance used to authenticate tokens.
:param base_url: Public base URL of this MA instance (used by FastMCP
to build the ``resource_metadata`` URL advertised in 401 responses
and the ``aud`` claim binding).
:param public_resource_uri: Canonical URI of the MCP server (the value
FastMCP will report as ``resource``). Used to populate
``AccessToken.resource`` so downstream code can audience-check.
:param enforce_audience: When ``True``, reject Bearer tokens whose
``aud`` claim is missing or does not contain ``public_resource_uri``.
When ``False`` (default), only logs a warning so operators can
migrate gracefully once MA-side issues audience-bound tokens.
"""
# ``base_url`` is optional on TokenVerifier — passing ``None`` is
# equivalent to not setting it. Forward verbatim so FastMCP can later
# build the resource_metadata URL from this verifier.
super().__init__(base_url=base_url)
self._mass = mass
self._public_resource_uri = public_resource_uri
self._enforce_audience = enforce_audience

async def verify_token(self, token: str) -> AccessToken | None:
"""Validate the bearer token and produce an ``AccessToken`` for FastMCP.

:param token: Raw bearer token from the ``Authorization`` header.
:return: ``AccessToken`` if the token is valid and the user is enabled,
otherwise ``None``.
"""
try:
user = await self._mass.webserver.auth.authenticate_with_token(token)
except Exception:
LOGGER.exception("MA token verification raised")
return None

if user is None or not getattr(user, "enabled", True):
return None

if not self._check_audience(token):
return None

# MCP SDK's AccessToken pydantic model has no `claims` field — extras
# are silently dropped — so we don't try to forward username/role here.
return AccessToken(
token=token,
client_id=str(getattr(user, "user_id", "")) or "music-assistant",
scopes=[],
expires_at=None,
resource=self._public_resource_uri,
)

def _check_audience(self, token: str) -> bool:
"""Return ``True`` if the token's audience is acceptable for this server.

In soft mode (``enforce_audience=False``) — always returns ``True`` and
only emits a warning when a JWT's ``aud`` is missing or mismatched.
In strict mode — rejects tokens missing or with a wrong ``aud``.
Non-JWT (legacy hash) tokens have no claim to inspect: they pass in
soft mode and fail in strict mode.
"""
expected = self._public_resource_uri
if not expected:
return True
aud = _extract_jwt_audience(token)
if _audience_matches(aud, expected):
return True
if self._enforce_audience:
LOGGER.warning(
"Rejected token: aud=%r does not match MCP resource URI %r",
aud,
expected,
)
return False
if aud is None:
LOGGER.debug(
"Token has no `aud` claim; accepting because enforce_audience=False",
)
else:
LOGGER.warning(
"Token aud=%r does not match resource URI %r; accepting because "
"enforce_audience=False (set CONF_ENFORCE_AUDIENCE to enforce).",
aud,
expected,
)
return True
Loading
Loading