/
/
/
1"""
2FastMCP sub-server for configuration view/edit tools.
3
4Spec: ``specs/inprogress/0006-config-read-write.md``.
5
6Read tools proxy ``mass.config.get_*`` (secrets masked by MA's
7``__post_serialize__``). Write tools (later registration functions)
8delegate to MA's atomic ``save_*_config``. All tools are gated by
9off-by-default ConfigEntries; with no tag enabled the namespace is
10invisible via ``TagFilterMiddleware``.
11"""
12# ruff: noqa: TID252 -- relative imports are the canonical MA-provider pattern.
13
14from __future__ import annotations
15
16import logging
17import time
18from typing import TYPE_CHECKING, Any
19
20from fastmcp import Context, FastMCP
21from fastmcp.exceptions import ToolError
22from mcp.types import ToolAnnotations
23from music_assistant_models.config_entries import ConfigActionResult
24from music_assistant_models.constants import SECURE_STRING_SUBSTITUTE
25from music_assistant_models.enums import ConfigEntryType
26
27from ..config_io.differ import compute_diff
28from ..config_io.secret_handler import gate_secret_writes
29from ..config_io.validator import coerce
30from ..models import (
31 ActionResult,
32 ConfigEntryDump,
33 ConfigEntryList,
34 ConfigTarget,
35 ConfigTargetList,
36 ConfigValueDump,
37 CoreConfigDump,
38 DSPConfigDump,
39 PlayerConfigDump,
40 ProviderConfigDump,
41 SaveResult,
42 SetValueResult,
43)
44from ..tags import Tag
45from ._common import (
46 TIMEOUT_FAST,
47 TIMEOUT_INTERACTIVE,
48 confirm_or_raise,
49 lean_schema_view,
50)
51
52if TYPE_CHECKING:
53 from collections.abc import Callable
54
55 from music_assistant_models.config_entries import (
56 ConfigEntry,
57 CoreConfig,
58 PlayerConfig,
59 ProviderConfig,
60 )
61
62 from music_assistant.mass import MusicAssistant
63
64
65LOGGER = logging.getLogger("music_assistant.providers.fastmcp_server.config")
66
67_PAYLOAD_CAP_BYTES = 256 * 1024
68_SAVE_PAYLOAD_CAP_BYTES = 64 * 1024
69
70
71def _resolve_secret_enabled(flag: bool | Callable[[], bool]) -> bool:
72 """
73 Resolve the secret-write gate at call time.
74
75 The runtime passes a callable that reads the current
76 ``CONF_CONFIG_WRITE_SECRET`` value so a hot-swapped permission toggle
77 takes effect on the next request (mirrors the TagFilterMiddleware
78 closure). Tests pass a plain bool.
79
80 :param flag: Either a plain bool or a zero-arg callable returning one.
81 """
82 return flag() if callable(flag) else flag
83
84
85def _readonly(title: str) -> ToolAnnotations:
86 return ToolAnnotations(
87 title=title,
88 readOnlyHint=True,
89 destructiveHint=False,
90 idempotentHint=True,
91 openWorldHint=False,
92 )
93
94
95def _values_from_raw(raw: dict[str, Any]) -> tuple[list[ConfigValueDump], bool]:
96 """Build ConfigValueDump list from a Config.to_dict() raw dict (secrets pre-masked)."""
97 out: list[ConfigValueDump] = []
98 truncated = False
99 running = 0
100 for key, entry in raw.get("values", {}).items():
101 value = entry.get("value")
102 etype = entry.get("type", "unknown")
103 size = len(str(value)) + len(str(key)) + len(str(etype)) + 16
104 if running + size > _PAYLOAD_CAP_BYTES:
105 truncated = True
106 break
107 running += size
108 out.append(ConfigValueDump(key=str(key), type=str(etype), value=value))
109 return out, truncated
110
111
112def _entry_dump(entry: ConfigEntry, current: Any) -> ConfigEntryDump:
113 """Map a ConfigEntry + current value to ConfigEntryDump."""
114 opts = [o.value for o in entry.options] if entry.options else None
115 # Label/description are resolved from the translations at serialization
116 # (server-category entries leave the raw attributes None), so read the
117 # localized values via to_dict() instead of the bare attributes.
118 localized = entry.to_dict()
119 # Mask secrets: _resolve_entries reads raw ConfigEntry.value, bypassing
120 # the to_dict()/__post_serialize__ hook the sibling read tools use.
121 current_value = (
122 SECURE_STRING_SUBSTITUTE
123 if entry.type == ConfigEntryType.SECURE_STRING and current is not None
124 else current
125 )
126 return ConfigEntryDump(
127 key=entry.key,
128 type=entry.type.value,
129 label=localized.get("label"),
130 default_value=entry.default_value,
131 required=entry.required,
132 description=localized.get("description"),
133 options=opts,
134 range=entry.range,
135 advanced=getattr(entry, "advanced", False),
136 hidden=entry.hidden,
137 requires_reload=entry.requires_reload,
138 depends_on=entry.depends_on,
139 action=entry.action,
140 current_value=current_value,
141 )
142
143
144async def _resolve_entries(
145 mass: MusicAssistant, target_type: str, target_id: str
146) -> tuple[list[ConfigEntry], dict[str, Any]]:
147 """
148 Fetch ConfigEntry list + current values dict for a target.
149
150 :param mass: MusicAssistant instance.
151 :param target_type: "provider" | "core" | "player".
152 :param target_id: The target identifier.
153 """
154 cfg: ProviderConfig | CoreConfig | PlayerConfig
155 if target_type == "provider":
156 cfg = await mass.config.get_provider_config(target_id)
157 entries = await mass.config.get_provider_config_entries(target_id)
158 elif target_type == "core":
159 cfg = await mass.config.get_core_config(target_id)
160 entries = await mass.config.get_core_config_entries(target_id)
161 elif target_type == "player":
162 cfg = await mass.config.get_player_config(target_id)
163 entries = await mass.config.get_player_config_entries(target_id)
164 else:
165 raise ToolError(f"unknown target_type {target_type!r}")
166 current = {k: getattr(v, "value", None) for k, v in getattr(cfg, "values", {}).items()}
167 return list(entries), current
168
169
170def _audit_id() -> str:
171 """Return a sortable, unique audit id (monotonic, no Date.now/random)."""
172 return f"cfg-{time.time_ns():x}"
173
174
175def _action_outcome(mass: MusicAssistant, result: ConfigActionResult) -> dict[str, Any]:
176 """
177 Render a config action's outcome as the tool's extra_data payload.
178
179 :param mass: The Music Assistant instance, used to localize the outcome message.
180 :param result: The outcome reported by the action handler.
181 """
182 message = result.message
183 if result.translation_key:
184 message = (
185 mass.translations.get_translation(
186 f"config_actions.{result.translation_key}",
187 owner=result.translation_owner,
188 params=[str(a) for a in result.translation_args] or None,
189 )
190 or message
191 )
192 return {
193 key: value
194 for key, value in (("message", message), ("open_url", result.open_url))
195 if value is not None
196 }
197
198
199def _confirm_prompt(target_type: str, target_id: str, keys: list[str]) -> str:
200 """
201 Build the confirmation prompt for a write (core warns about restart).
202
203 :param target_type: "provider" | "core" | "player".
204 :param target_id: The target identifier.
205 :param keys: Config keys being written.
206 """
207 if target_type == "core":
208 return (
209 f"Save core {target_id!r} config ({', '.join(keys)})? "
210 "Core changes may restart subsystems and interrupt ALL playback."
211 )
212 return f"Save {target_type} {target_id!r} config ({', '.join(keys)})?"
213
214
215async def _do_save(
216 mass: MusicAssistant, target_type: str, target_id: str, values: dict[str, Any]
217) -> None:
218 """
219 Delegate to MA's atomic save_*_config (validate+encrypt+persist+reload).
220
221 :param mass: MusicAssistant instance.
222 :param target_type: "provider" | "core" | "player".
223 :param target_id: The target identifier.
224 :param values: Plaintext keyâvalue map to persist (MA encrypts SECURE_STRING).
225 """
226 try:
227 if target_type == "provider":
228 cfg = await mass.config.get_provider_config(target_id)
229 await mass.config.save_provider_config(
230 getattr(cfg, "domain", target_id), values, instance_id=target_id
231 )
232 elif target_type == "core":
233 await mass.config.save_core_config(target_id, values)
234 elif target_type == "player":
235 await mass.config.save_player_config(target_id, values)
236 else:
237 raise ToolError(f"unknown target_type {target_type!r}")
238 except ToolError:
239 raise
240 except Exception as exc:
241 raise ToolError(f"config save failed: {exc}") from exc
242
243
244async def _write_single(
245 mass: MusicAssistant,
246 target_type: str,
247 target_id: str,
248 key: str,
249 value: Any,
250 *,
251 dry_run: bool,
252 ctx: Context | None,
253 require_confirmation: bool,
254 secret_writes_enabled: bool | Callable[[], bool],
255) -> SetValueResult:
256 """
257 Validate â secret-gate â diff â (confirm â audit â save) for one key.
258
259 :param mass: MusicAssistant instance.
260 :param target_type: "provider" | "core" | "player".
261 :param target_id: The target identifier.
262 :param key: Config key to write.
263 :param value: Proposed value (plaintext; MA encrypts SECURE_STRING).
264 :param dry_run: When True, return a diff without writing.
265 :param ctx: FastMCP context (may be None in unit tests).
266 :param require_confirmation: When True, elicit confirmation before writing.
267 :param secret_writes_enabled: Bool or callable returning bool; resolved
268 per request so a hot-swapped toggle takes effect immediately.
269 """
270 entries_list, current = await _resolve_entries(mass, target_type, target_id)
271 entries = {e.key: e for e in entries_list}
272 if key not in entries:
273 raise ToolError(f"unknown key {key!r} for {target_type} {target_id!r}")
274 parsed = coerce(entries[key], value)
275 gate_secret_writes(
276 entries, {key: parsed}, secret_tag_enabled=_resolve_secret_enabled(secret_writes_enabled)
277 )
278 diff = compute_diff(
279 target_type=target_type,
280 target_id=target_id,
281 entries=entries,
282 current=current,
283 proposed={key: parsed},
284 )
285 requires_reload = bool(entries[key].requires_reload)
286 if dry_run:
287 return SetValueResult(
288 target_type=target_type,
289 target_id=target_id,
290 key=key,
291 applied=False,
292 requires_reload=requires_reload,
293 audit_log_id="",
294 diff=diff,
295 )
296 await confirm_or_raise(
297 ctx, _confirm_prompt(target_type, target_id, [key]), enabled=require_confirmation
298 )
299 audit = _audit_id()
300 LOGGER.info(
301 "config_write target=%s id=%s key=%s audit_id=%s", target_type, target_id, key, audit
302 )
303 await _do_save(mass, target_type, target_id, {key: parsed})
304 return SetValueResult(
305 target_type=target_type,
306 target_id=target_id,
307 key=key,
308 applied=True,
309 requires_reload=requires_reload,
310 audit_log_id=audit,
311 diff=None,
312 )
313
314
315async def _write_bulk(
316 mass: MusicAssistant,
317 target_type: str,
318 target_id: str,
319 values: dict[str, Any],
320 *,
321 dry_run: bool,
322 ctx: Context | None,
323 require_confirmation: bool,
324 secret_writes_enabled: bool | Callable[[], bool],
325) -> SaveResult:
326 """
327 Validate-all â secret-gate â diff â (confirm â audit â atomic save) for a payload.
328
329 :param mass: MusicAssistant instance.
330 :param target_type: "provider" | "core" | "player".
331 :param target_id: The target identifier.
332 :param values: Proposed keyâvalue map (plaintext; MA encrypts SECURE_STRING).
333 :param dry_run: When True, return a diff without writing.
334 :param ctx: FastMCP context (may be None in unit tests).
335 :param require_confirmation: When True, elicit confirmation before writing.
336 :param secret_writes_enabled: Bool or callable returning bool; resolved
337 per request so a hot-swapped toggle takes effect immediately.
338 """
339 import json # noqa: PLC0415
340
341 if len(json.dumps(values, default=str)) > _SAVE_PAYLOAD_CAP_BYTES:
342 raise ToolError("save payload exceeds 64 KB cap")
343 entries_list, current = await _resolve_entries(mass, target_type, target_id)
344 entries = {e.key: e for e in entries_list}
345 parsed: dict[str, Any] = {}
346 for key, raw in values.items():
347 if key not in entries:
348 raise ToolError(f"unknown key {key!r} for {target_type} {target_id!r}")
349 parsed[key] = coerce(entries[key], raw)
350 gate_secret_writes(
351 entries, parsed, secret_tag_enabled=_resolve_secret_enabled(secret_writes_enabled)
352 )
353 diff = compute_diff(
354 target_type=target_type,
355 target_id=target_id,
356 entries=entries,
357 current=current,
358 proposed=parsed,
359 )
360 requires_reload = any(entries[k].requires_reload for k in parsed)
361 if dry_run:
362 return SaveResult(
363 target_type=target_type,
364 target_id=target_id,
365 applied=False,
366 changes=diff.changes,
367 requires_reload=requires_reload,
368 audit_log_id="",
369 diff=diff,
370 )
371 await confirm_or_raise(
372 ctx, _confirm_prompt(target_type, target_id, list(parsed)), enabled=require_confirmation
373 )
374 audit = _audit_id()
375 LOGGER.info(
376 "config_save target=%s id=%s keys=%s audit_id=%s",
377 target_type,
378 target_id,
379 sorted(parsed),
380 audit,
381 )
382 await _do_save(mass, target_type, target_id, parsed)
383 return SaveResult(
384 target_type=target_type,
385 target_id=target_id,
386 applied=True,
387 changes=diff.changes,
388 requires_reload=requires_reload,
389 audit_log_id=audit,
390 diff=None,
391 )
392
393
394def build_config_server(
395 mass: MusicAssistant,
396 *,
397 require_confirmation: bool = True,
398 secret_writes_enabled: bool | Callable[[], bool] = True,
399 lean_schema: bool = False,
400) -> FastMCP:
401 """
402 Build the ``config`` sub-server.
403
404 :param mass: MusicAssistant instance.
405 :param require_confirmation: When True (default), every write elicits
406 confirmation before mutating.
407 :param secret_writes_enabled: Bool or zero-arg callable returning bool.
408 When False (or the callable returns False), SECURE_STRING writes are
409 rejected. The runtime passes a callable so a hot-swapped permission
410 toggle takes effect on the next request without a rebuild.
411 :param lean_schema: When True, tools omit their ``outputSchema`` to shrink
412 the namespace's context footprint for hosts without tool-search.
413 """
414 sub = FastMCP(name="config")
415 target = lean_schema_view(sub) if lean_schema else sub
416 _register_read_tools(target, mass)
417 _register_provider_write_tools(
418 target,
419 mass,
420 require_confirmation=require_confirmation,
421 secret_writes_enabled=secret_writes_enabled,
422 )
423 _register_core_write_tools(
424 target,
425 mass,
426 require_confirmation=require_confirmation,
427 secret_writes_enabled=secret_writes_enabled,
428 )
429 _register_player_write_tools(
430 target,
431 mass,
432 require_confirmation=require_confirmation,
433 secret_writes_enabled=secret_writes_enabled,
434 )
435 return sub
436
437
438def _register_read_tools(sub: FastMCP, mass: MusicAssistant) -> None:
439 @sub.tool(
440 tags={Tag.CONFIG_READ},
441 annotations=_readonly("List configurable targets"),
442 timeout=TIMEOUT_FAST,
443 )
444 async def list_targets() -> ConfigTargetList:
445 """
446 List every configurable provider, core controller, and player.
447
448 See also: config_get_provider / config_get_core / config_get_player
449 for a single target's values, config_get_entries for the editable
450 schema.
451 """
452 providers = [
453 ConfigTarget(
454 target_type="provider",
455 target_id=getattr(c, "instance_id", ""),
456 domain=getattr(c, "domain", ""),
457 name=getattr(c, "name", "") or getattr(c, "domain", ""),
458 enabled=bool(getattr(c, "enabled", True)),
459 )
460 for c in await mass.config.get_provider_configs()
461 ]
462 core = [
463 ConfigTarget(
464 target_type="core",
465 target_id=getattr(c, "domain", ""),
466 domain=getattr(c, "domain", ""),
467 name=getattr(c, "domain", ""),
468 enabled=True,
469 )
470 for c in await mass.config.get_core_configs()
471 ]
472 players = [
473 ConfigTarget(
474 target_type="player",
475 target_id=getattr(c, "player_id", ""),
476 domain=getattr(c, "provider", ""),
477 name=getattr(c, "name", "") or getattr(c, "player_id", ""),
478 enabled=bool(getattr(c, "enabled", True)),
479 )
480 for c in await mass.config.get_player_configs()
481 ]
482 return ConfigTargetList(providers=providers, core=core, players=players)
483
484 @sub.tool(
485 tags={Tag.CONFIG_READ},
486 annotations=_readonly("Get provider config"),
487 timeout=TIMEOUT_FAST,
488 )
489 async def get_provider(instance_id: str) -> ProviderConfigDump:
490 """
491 Return a provider's stored config values (SECURE_STRING masked).
492
493 See also: config_get_entries for editable schema, config_set_provider_value
494 to change one value, config_save_provider for bulk.
495
496 :param instance_id: The provider instance identifier.
497 """
498 try:
499 cfg = await mass.config.get_provider_config(instance_id)
500 except Exception as exc:
501 raise ToolError(f"provider instance_id={instance_id!r} not found") from exc
502 values, truncated = _values_from_raw(cfg.to_dict())
503 return ProviderConfigDump(
504 instance_id=instance_id,
505 domain=getattr(cfg, "domain", ""),
506 values=values,
507 truncated=truncated,
508 )
509
510 @sub.tool(
511 tags={Tag.CONFIG_READ},
512 annotations=_readonly("Get core config"),
513 timeout=TIMEOUT_FAST,
514 )
515 async def get_core(domain: str) -> CoreConfigDump:
516 """
517 Return a core controller's stored config values (SECURE_STRING masked).
518
519 See also: config_set_core_value / config_save_core to change them.
520
521 :param domain: Core controller domain (e.g. "webserver", "streams").
522 """
523 try:
524 cfg = await mass.config.get_core_config(domain)
525 except Exception as exc:
526 raise ToolError(f"core domain={domain!r} not found") from exc
527 values, truncated = _values_from_raw(cfg.to_dict())
528 return CoreConfigDump(domain=domain, values=values, truncated=truncated)
529
530 @sub.tool(
531 tags={Tag.CONFIG_READ},
532 annotations=_readonly("Get player config"),
533 timeout=TIMEOUT_FAST,
534 )
535 async def get_player(player_id: str) -> PlayerConfigDump:
536 """
537 Return a player's stored config values (SECURE_STRING masked).
538
539 See also: config_set_player_value to change one, config_get_dsp for EQ/DSP.
540
541 :param player_id: The player identifier.
542 """
543 try:
544 cfg = await mass.config.get_player_config(player_id)
545 except Exception as exc:
546 raise ToolError(f"player_id={player_id!r} not found") from exc
547 values, truncated = _values_from_raw(cfg.to_dict())
548 return PlayerConfigDump(
549 player_id=player_id,
550 provider=getattr(cfg, "provider", ""),
551 values=values,
552 truncated=truncated,
553 )
554
555 @sub.tool(
556 tags={Tag.CONFIG_READ},
557 annotations=_readonly("Get editable config entries"),
558 timeout=TIMEOUT_FAST,
559 )
560 async def get_entries(target_type: str, target_id: str) -> ConfigEntryList:
561 """
562 Return the editable ConfigEntry schema for a target.
563
564 See also: config_set_*_value to write a key. Action-driven entries are
565 triggered separately via config_trigger_provider_action.
566
567 :param target_type: "provider" | "core" | "player".
568 :param target_id: The target identifier (instance_id / domain / player_id).
569 """
570 entries, current = await _resolve_entries(mass, target_type, target_id)
571 dumps = [_entry_dump(e, current.get(e.key)) for e in entries]
572 return ConfigEntryList(
573 target_type=target_type, target_id=target_id, entries=dumps, truncated=False
574 )
575
576 @sub.tool(
577 tags={Tag.CONFIG_READ},
578 annotations=_readonly("Get player DSP config"),
579 timeout=TIMEOUT_FAST,
580 )
581 async def get_dsp(player_id: str) -> DSPConfigDump:
582 """
583 Return a player's DSP configuration (enabled, gains, filter chain).
584
585 See also: config_save_dsp to change it.
586
587 :param player_id: The player identifier.
588 """
589 try:
590 await mass.config.get_player_config(player_id)
591 except Exception as exc:
592 raise ToolError(f"player_id={player_id!r} not found") from exc
593 dsp = mass.config.get_player_dsp_config(player_id)
594 raw = dsp.to_dict()
595 return DSPConfigDump(
596 player_id=player_id,
597 enabled=bool(raw.get("enabled", False)),
598 input_gain=float(raw.get("input_gain", 0.0)),
599 output_gain=float(raw.get("output_gain", 0.0)),
600 filters=list(raw.get("filters", [])),
601 )
602
603
604def _register_provider_write_tools(
605 sub: FastMCP,
606 mass: MusicAssistant,
607 *,
608 require_confirmation: bool,
609 secret_writes_enabled: bool | Callable[[], bool],
610) -> None:
611 @sub.tool(
612 tags={Tag.CONFIG_WRITE_PROVIDER},
613 annotations=ToolAnnotations(
614 title="Set provider config value",
615 destructiveHint=True,
616 idempotentHint=False,
617 ),
618 timeout=TIMEOUT_INTERACTIVE,
619 )
620 async def set_provider_value(
621 instance_id: str, key: str, value: Any, dry_run: bool = False, ctx: Context | None = None
622 ) -> SetValueResult:
623 """
624 Set one provider config value.
625
626 Validates the value, gates SECURE_STRING writes behind
627 config:write:secret, then delegates to MA's atomic
628 save_provider_config (validate, encrypt, persist, reload).
629 ``dry_run=True`` returns a before/after diff without writing.
630 See also: config_get_entries for editable keys.
631
632 :param instance_id: Provider instance identifier.
633 :param key: ConfigEntry key to set.
634 :param value: New value.
635 :param dry_run: When True, return a diff and do not persist.
636 :param ctx: FastMCP context (auto-populated).
637 """
638 return await _write_single(
639 mass,
640 "provider",
641 instance_id,
642 key,
643 value,
644 dry_run=dry_run,
645 ctx=ctx,
646 require_confirmation=require_confirmation,
647 secret_writes_enabled=secret_writes_enabled,
648 )
649
650 @sub.tool(
651 tags={Tag.CONFIG_WRITE_PROVIDER},
652 annotations=ToolAnnotations(
653 title="Save provider config (bulk)",
654 destructiveHint=True,
655 idempotentHint=False,
656 ),
657 timeout=TIMEOUT_INTERACTIVE,
658 )
659 async def save_provider(
660 instance_id: str,
661 values: dict[str, Any],
662 dry_run: bool = False,
663 ctx: Context | None = None,
664 ) -> SaveResult:
665 """
666 Bulk-save provider config values (atomic at MA's layer).
667
668 :param instance_id: Provider instance identifier.
669 :param values: key->value map to apply.
670 :param dry_run: When True, return a diff and do not persist.
671 :param ctx: FastMCP context (auto-populated).
672 """
673 return await _write_bulk(
674 mass,
675 "provider",
676 instance_id,
677 values,
678 dry_run=dry_run,
679 ctx=ctx,
680 require_confirmation=require_confirmation,
681 secret_writes_enabled=secret_writes_enabled,
682 )
683
684 @sub.tool(
685 tags={Tag.CONFIG_WRITE_PROVIDER},
686 annotations=ToolAnnotations(
687 title="Trigger provider config action",
688 destructiveHint=True,
689 idempotentHint=False,
690 ),
691 timeout=TIMEOUT_INTERACTIVE,
692 )
693 async def trigger_provider_action(
694 instance_id: str,
695 action_key: str,
696 ctx: Context | None = None,
697 ) -> ActionResult:
698 """
699 Invoke a provider config action (e.g. QR login, clear auth).
700
701 Always elicits confirmation, even when require_confirmation is off.
702
703 :param instance_id: Provider instance identifier.
704 :param action_key: The action ConfigEntry key.
705 :param ctx: FastMCP context (auto-populated).
706 """
707 await confirm_or_raise(
708 ctx,
709 f"Run provider action {action_key!r} on {instance_id!r}?",
710 enabled=True,
711 )
712 result = await mass.config.invoke_provider_config_action(instance_id, action_key)
713 audit = _audit_id()
714 LOGGER.info(
715 "config_action provider=%s action=%s audit_id=%s", instance_id, action_key, audit
716 )
717 if isinstance(result, ConfigActionResult):
718 return ActionResult(
719 instance_id=instance_id,
720 action_key=action_key,
721 new_entries=[],
722 extra_data=_action_outcome(mass, result),
723 audit_log_id=audit,
724 )
725 return ActionResult(
726 instance_id=instance_id,
727 action_key=action_key,
728 new_entries=[_entry_dump(e, getattr(e, "value", None)) for e in result],
729 extra_data={},
730 audit_log_id=audit,
731 )
732
733
734def _register_core_write_tools(
735 sub: FastMCP,
736 mass: MusicAssistant,
737 *,
738 require_confirmation: bool,
739 secret_writes_enabled: bool | Callable[[], bool],
740) -> None:
741 @sub.tool(
742 tags={Tag.CONFIG_WRITE_CORE},
743 annotations=ToolAnnotations(
744 title="Set core config value",
745 destructiveHint=True,
746 idempotentHint=False,
747 ),
748 timeout=TIMEOUT_INTERACTIVE,
749 )
750 async def set_core_value(
751 domain: str, key: str, value: Any, dry_run: bool = False, ctx: Context | None = None
752 ) -> SetValueResult:
753 """
754 Set one core controller config value.
755
756 Core changes may restart subsystems and interrupt all playback.
757 ``dry_run=True`` previews without writing. See also: config_get_core
758 for current values.
759
760 :param domain: Core controller domain (e.g. "webserver", "streams").
761 :param key: ConfigEntry key.
762 :param value: New value.
763 :param dry_run: When True, return a diff and do not persist.
764 :param ctx: FastMCP context (auto-populated).
765 """
766 return await _write_single(
767 mass,
768 "core",
769 domain,
770 key,
771 value,
772 dry_run=dry_run,
773 ctx=ctx,
774 require_confirmation=require_confirmation,
775 secret_writes_enabled=secret_writes_enabled,
776 )
777
778 @sub.tool(
779 tags={Tag.CONFIG_WRITE_CORE},
780 annotations=ToolAnnotations(
781 title="Save core config (bulk)",
782 destructiveHint=True,
783 idempotentHint=False,
784 ),
785 timeout=TIMEOUT_INTERACTIVE,
786 )
787 async def save_core(
788 domain: str, values: dict[str, Any], dry_run: bool = False, ctx: Context | None = None
789 ) -> SaveResult:
790 """
791 Bulk-save core controller config values.
792
793 :param domain: Core controller domain.
794 :param values: key->value map.
795 :param dry_run: When True, return a diff and do not persist.
796 :param ctx: FastMCP context (auto-populated).
797 """
798 return await _write_bulk(
799 mass,
800 "core",
801 domain,
802 values,
803 dry_run=dry_run,
804 ctx=ctx,
805 require_confirmation=require_confirmation,
806 secret_writes_enabled=secret_writes_enabled,
807 )
808
809
810def _register_player_write_tools(
811 sub: FastMCP,
812 mass: MusicAssistant,
813 *,
814 require_confirmation: bool,
815 secret_writes_enabled: bool | Callable[[], bool],
816) -> None:
817 @sub.tool(
818 tags={Tag.CONFIG_WRITE_PLAYER},
819 annotations=ToolAnnotations(
820 title="Set player config value", destructiveHint=True, idempotentHint=False
821 ),
822 timeout=TIMEOUT_INTERACTIVE,
823 )
824 async def set_player_value(
825 player_id: str, key: str, value: Any, dry_run: bool = False, ctx: Context | None = None
826 ) -> SetValueResult:
827 """
828 Set one player config value (volume limits, crossfade, output protocol, ...).
829
830 See also: config_get_player for current values, config_save_dsp for EQ/DSP.
831
832 :param player_id: The player identifier.
833 :param key: ConfigEntry key.
834 :param value: New value.
835 :param dry_run: When True, return a diff and do not persist.
836 :param ctx: FastMCP context (auto-populated).
837 """
838 return await _write_single(
839 mass,
840 "player",
841 player_id,
842 key,
843 value,
844 dry_run=dry_run,
845 ctx=ctx,
846 require_confirmation=require_confirmation,
847 secret_writes_enabled=secret_writes_enabled,
848 )
849
850 @sub.tool(
851 tags={Tag.CONFIG_WRITE_PLAYER},
852 annotations=ToolAnnotations(
853 title="Save player config (bulk)", destructiveHint=True, idempotentHint=False
854 ),
855 timeout=TIMEOUT_INTERACTIVE,
856 )
857 async def save_player(
858 player_id: str, values: dict[str, Any], dry_run: bool = False, ctx: Context | None = None
859 ) -> SaveResult:
860 """
861 Bulk-save player config values.
862
863 :param player_id: The player identifier.
864 :param values: key->value map.
865 :param dry_run: When True, return a diff and do not persist.
866 :param ctx: FastMCP context (auto-populated).
867 """
868 return await _write_bulk(
869 mass,
870 "player",
871 player_id,
872 values,
873 dry_run=dry_run,
874 ctx=ctx,
875 require_confirmation=require_confirmation,
876 secret_writes_enabled=secret_writes_enabled,
877 )
878
879 @sub.tool(
880 tags={Tag.CONFIG_WRITE_PLAYER},
881 annotations=ToolAnnotations(
882 title="Save player DSP config", destructiveHint=True, idempotentHint=False
883 ),
884 timeout=TIMEOUT_INTERACTIVE,
885 )
886 async def save_dsp(
887 player_id: str, dsp: dict[str, Any], dry_run: bool = False, ctx: Context | None = None
888 ) -> SaveResult:
889 """
890 Save a player's DSP configuration (enabled, gains, filter chain).
891
892 The payload is parsed via DSPConfig.from_dict and validated (gains
893 must be within -60..60 dB, filters well-formed). See also:
894 config_get_dsp for the current DSP shape.
895
896 :param player_id: The player identifier.
897 :param dsp: DSPConfig as a dict (enabled, input_gain, output_gain, filters).
898 :param dry_run: When True, report the proposed change without writing.
899 :param ctx: FastMCP context (auto-populated).
900 """
901 import json # noqa: PLC0415
902
903 from music_assistant_models.dsp import DSPConfig # noqa: PLC0415
904
905 if len(json.dumps(dsp, default=str)) > _SAVE_PAYLOAD_CAP_BYTES:
906 raise ToolError("save payload exceeds 64 KB cap")
907 try:
908 cfg = DSPConfig.from_dict(dsp)
909 cfg.validate()
910 except ToolError:
911 raise
912 except Exception as exc:
913 raise ToolError(f"invalid DSP config: {exc}") from exc
914 if dry_run:
915 return SaveResult(
916 target_type="player_dsp",
917 target_id=player_id,
918 applied=False,
919 changes=[],
920 requires_reload=False,
921 audit_log_id="",
922 diff=None,
923 )
924 await confirm_or_raise(
925 ctx, f"Save DSP config for player {player_id!r}?", enabled=require_confirmation
926 )
927 audit = _audit_id()
928 LOGGER.info("config_save_dsp player=%s audit_id=%s", player_id, audit)
929 try:
930 await mass.config.save_dsp_config(player_id, cfg)
931 except Exception as exc:
932 raise ToolError(f"DSP save failed: {exc}") from exc
933 return SaveResult(
934 target_type="player_dsp",
935 target_id=player_id,
936 applied=True,
937 changes=[],
938 requires_reload=False,
939 audit_log_id=audit,
940 diff=None,
941 )
942