gabriel / musehub public
elicitation_tools.py python
894 lines 35.5 KB
0bfb4569 add agent rules: Muse-only VCS, no Git/GitHub gabriel 9h ago
1 """Elicitation-powered tool executors for MCP 2025-11-25.
2
3 These tools use ``ToolCallContext.elicit_form()`` and ``elicit_url()`` to
4 collect structured input from users mid-tool-call. They require an active
5 session (``Mcp-Session-Id``) and a client that has declared elicitation
6 capability. When running without a session they degrade gracefully.
7
8 Tools in this module:
9
10 musehub_create_with_preferences (formerly musehub_compose_with_preferences)
11 Form-mode: collects key, tempo, mood, genre, then generates a full
12 composition plan (chord progressions, structure, tempo map) as a Muse
13 project scaffold. Currently implements the MIDI domain workflow.
14
15 musehub_review_pr_interactive
16 Form-mode: collects dimension focus and review depth before running a
17 deep musical divergence analysis of the PR.
18
19 musehub_connect_streaming_platform
20 URL-mode: OAuth-connects a streaming platform (Spotify, SoundCloud, etc.)
21 for agent-triggered release distribution.
22
23 musehub_connect_daw_cloud
24 URL-mode: OAuth-connects a cloud DAW service (LANDR, Splice, etc.) to
25 enable cloud renders and mastering jobs.
26
27 musehub_create_release_interactive
28 Chained: form-mode collects release metadata, URL-mode optionally connects
29 streaming platforms, then creates the release.
30 """
31 from __future__ import annotations
32
33
34 import logging
35 from typing import TYPE_CHECKING
36
37 from musehub.contracts.json_types import JSONValue
38 from musehub.mcp.elicitation import (
39 SCHEMAS,
40 AVAILABLE_PLATFORMS,
41 AVAILABLE_DAW_CLOUDS,
42 oauth_connect_url,
43 daw_cloud_connect_url,
44 )
45 from musehub.services.musehub_mcp_executor import MusehubToolResult, _check_db_available
46
47 if TYPE_CHECKING:
48 from musehub.mcp.context import ToolCallContext
49
50 logger = logging.getLogger(__name__)
51
52
53 # ── Composition with preferences ─────────────────────────────────────────────
54
55
56 async def execute_compose_with_preferences(
57 repo_id: str | None,
58 *,
59 preferences: dict[str, JSONValue] | None = None,
60 ctx: "ToolCallContext",
61 ) -> MusehubToolResult:
62 """Compose a musical piece, optionally eliciting preferences via MCP.
63
64 Bypass path (no session needed): pass ``preferences`` directly to skip
65 elicitation and receive a composition plan immediately. Missing fields
66 fall back to sensible defaults.
67
68 Elicitation path (session required): when ``preferences`` is omitted and
69 an active MCP session exists, a form is presented to collect preferences
70 interactively.
71
72 No-session, no-preferences path: returns a structured guide listing every
73 available field so the caller can populate ``preferences`` and call again.
74
75 Args:
76 repo_id: Optional target repository for the composition scaffold.
77 preferences: Pre-filled preference dict (bypasses elicitation).
78 ctx: Tool call context (session required for elicitation).
79 """
80 # ── Bypass: preferences provided directly ─────────────────────────────────
81 if preferences is not None:
82 key = str(preferences.get("key_signature") or preferences.get("key") or "C major")
83 _tempo = preferences.get("tempo_bpm", 120)
84 tempo = int(_tempo) if isinstance(_tempo, (int, float)) else 120
85 time_sig = str(preferences.get("time_signature") or "4/4")
86 mood = str(preferences.get("mood") or "peaceful")
87 genre = str(preferences.get("genre") or "ambient")
88 reference = str(preferences.get("reference_artist") or "")
89 _duration = preferences.get("duration_bars", 64)
90 duration = int(_duration) if isinstance(_duration, (int, float)) else 64
91 modulate = bool(preferences.get("include_modulation", False))
92 plan = _build_composition_plan(
93 key=key, tempo=tempo, time_sig=time_sig, mood=mood, genre=genre,
94 reference=reference, duration_bars=duration, modulate=modulate,
95 )
96 if repo_id:
97 plan["repo_id"] = repo_id
98 plan["scaffold_hint"] = (
99 f"Create a commit in repo {repo_id} with a 'composition.json' file "
100 "containing the above plan. Use musehub_create_issue to track each "
101 "section as a task."
102 )
103 return MusehubToolResult(ok=True, data=plan)
104
105 # ── No session and no preferences: return field guide ─────────────────────
106 if not ctx.has_session:
107 return MusehubToolResult(
108 ok=True,
109 data={
110 "mode": "schema_guide",
111 "message": (
112 "No active MCP session — pass 'preferences' directly to bypass elicitation. "
113 "Call musehub_create_with_preferences(preferences={...}) with the fields below."
114 ),
115 "fields": {
116 "key_signature": {"type": "string", "example": "C major", "options": [
117 "C major", "G major", "D major", "A major", "E major",
118 "F major", "Bb major", "Eb major",
119 "A minor", "E minor", "D minor", "G minor",
120 ]},
121 "tempo_bpm": {"type": "integer", "example": 120, "range": "40–240"},
122 "time_signature": {"type": "string", "example": "4/4", "options": ["4/4", "3/4", "6/8", "5/4"]},
123 "mood": {"type": "string", "example": "peaceful", "options": [
124 "joyful", "melancholic", "tense", "peaceful", "energetic",
125 "mysterious", "romantic", "triumphant", "nostalgic", "ethereal",
126 ]},
127 "genre": {"type": "string", "example": "jazz", "options": [
128 "ambient", "jazz", "classical", "electronic", "hip-hop",
129 "folk", "film score", "pop", "r&b", "lo-fi",
130 ]},
131 "reference_artist": {"type": "string", "example": "Bill Evans"},
132 "duration_bars": {"type": "integer", "example": 64, "range": "8–256"},
133 "include_modulation": {"type": "boolean", "example": False},
134 },
135 },
136 )
137
138 # ── Elicitation path: active session ──────────────────────────────────────
139 await ctx.progress("compose", 0, 4, "Requesting composition preferences from user…")
140
141 prefs = await ctx.elicit_form(
142 SCHEMAS["compose_preferences"],
143 message=(
144 "Let's build your composition! Tell me about the piece you want to create. "
145 "These preferences will shape the chord progressions, structure, and feel."
146 ),
147 )
148
149 if prefs is None:
150 return MusehubToolResult(
151 ok=False,
152 error_code="elicitation_declined",
153 error_message="User declined or cancelled the composition preferences form.",
154 )
155
156 await ctx.progress("compose", 1, 4, "Generating harmonic framework…")
157
158 key = str(prefs.get("key", "C major"))
159 _tempo = prefs.get("tempo_bpm", 120)
160 tempo = int(_tempo) if isinstance(_tempo, (int, float)) else 120
161 time_sig = str(prefs.get("time_signature", "4/4"))
162 mood = str(prefs.get("mood", "peaceful"))
163 genre = str(prefs.get("genre", "ambient"))
164 reference = str(prefs.get("reference_artist", ""))
165 _duration = prefs.get("duration_bars", 64)
166 duration = int(_duration) if isinstance(_duration, (int, float)) else 64
167 modulate = bool(prefs.get("include_modulation", False))
168
169 plan = _build_composition_plan(
170 key=key,
171 tempo=tempo,
172 time_sig=time_sig,
173 mood=mood,
174 genre=genre,
175 reference=reference,
176 duration_bars=duration,
177 modulate=modulate,
178 )
179
180 await ctx.progress("compose", 2, 4, "Designing section structure…")
181
182 if repo_id:
183 plan["repo_id"] = repo_id
184 plan["scaffold_hint"] = (
185 f"Create a commit in repo {repo_id} with a 'composition.json' file "
186 f"containing the above plan. Use musehub_create_issue to track each "
187 f"section as a task."
188 )
189
190 await ctx.progress("compose", 4, 4, "Composition plan ready.")
191
192 return MusehubToolResult(ok=True, data=plan)
193
194
195 def _build_composition_plan(
196 *,
197 key: str,
198 tempo: int,
199 time_sig: str,
200 mood: str,
201 genre: str,
202 reference: str,
203 duration_bars: int,
204 modulate: bool,
205 ) -> dict[str, JSONValue]:
206 """Generate a structured composition plan from user preferences."""
207 # Key → relative minor / parallel minor / subdominant / dominant
208 _chords_by_key: dict[str, list[JSONValue]] = {
209 "C major": ["Cmaj7", "Am7", "Fmaj7", "G7"],
210 "G major": ["Gmaj7", "Em7", "Cmaj7", "D7"],
211 "D major": ["Dmaj7", "Bm7", "Gmaj7", "A7"],
212 "A major": ["Amaj7", "F#m7", "Dmaj7", "E7"],
213 "E major": ["Emaj7", "C#m7", "Amaj7", "B7"],
214 "F major": ["Fmaj7", "Dm7", "Bbmaj7", "C7"],
215 "Bb major": ["Bbmaj7", "Gm7", "Ebmaj7", "F7"],
216 "Eb major": ["Ebmaj7", "Cm7", "Abmaj7", "Bb7"],
217 "A minor": ["Am7", "Fmaj7", "G7", "Em7b5"],
218 "E minor": ["Em7", "Cmaj7", "D7", "Bm7b5"],
219 "D minor": ["Dm7", "Bbmaj7", "C7", "Am7b5"],
220 "G minor": ["Gm7", "Ebmaj7", "F7", "Dm7b5"],
221 }
222 chords: list[JSONValue] = _chords_by_key.get(key, ["Imaj7", "vim7", "IVmaj7", "V7"])
223
224 # Mood → harmonic tension profile
225 tension_map: dict[str, str] = {
226 "joyful": "consonant — favour I, IV, V; avoid diminished",
227 "melancholic": "suspended — favour vi, ii, bVII; resolve gently",
228 "tense": "dissonant — favour #IVdim, bII7; delay resolution",
229 "peaceful": "open — favour Iadd9, IVmaj9; long sustain",
230 "energetic": "driving — power chords, fast harmonic rhythm (2 bars)",
231 "mysterious": "modal — borrow from Dorian / Phrygian; tritone substitutions",
232 "romantic": "lush — Imaj9, VImaj7, ii7, V13; rich voicings",
233 "triumphant": "epic — I, V/V, bVII, I; orchestral swell",
234 "nostalgic": "bittersweet — I, vi, IV, V with added 9ths",
235 "ethereal": "ambient — suspended 2nds, no clear root; wash of colour",
236 }
237 tension_profile = tension_map.get(mood, "balanced — standard diatonic motion")
238
239 # Genre → texture hint
240 texture_map: dict[str, str] = {
241 "ambient": "slow harmonic rhythm, long reverb tails, minimal percussion",
242 "jazz": "walking bass, shell voicings (3rd+7th), improvised melody",
243 "classical": "counterpoint, voice-leading priority, no percussion",
244 "electronic": "side-chain compression, synth pads, 808 bass",
245 "hip-hop": "boom-bap drums, sample chops, 8-bar loops",
246 "folk": "acoustic guitar strums, simple melody, lyrical phrasing",
247 "film score": "orchestral strings, ostinato, dynamic swells",
248 "lo-fi": "vinyl crackle, detuned piano, laid-back swing",
249 "neo-soul": "Rhodes piano, syncopated bass, lush background vocals",
250 "experimental": "atonal passages, prepared piano, field recordings",
251 }
252 texture = texture_map.get(genre, "balanced — choose instrumentation freely")
253
254 # Structure (bars allocation)
255 sections: list[JSONValue] = []
256 remaining = duration_bars
257 parts = [
258 ("intro", max(4, duration_bars // 8)),
259 ("verse_a", max(8, duration_bars // 4)),
260 ("chorus", max(8, duration_bars // 4)),
261 ("verse_b", max(8, duration_bars // 4)),
262 ("chorus_2", max(8, duration_bars // 4)),
263 ]
264 if modulate:
265 parts.append(("bridge_modulation", 8))
266 parts.append(("chorus_final", max(8, duration_bars // 4)))
267 parts.append(("outro", max(4, duration_bars // 8)))
268
269 for section_name, section_bars in parts:
270 sections.append({"name": section_name, "bars": section_bars, "chords": chords})
271 remaining -= section_bars
272 if remaining <= 0:
273 break
274
275 intro_bars = parts[0][1]
276 return {
277 "composition_plan": {
278 "key": key,
279 "tempo_bpm": tempo,
280 "time_signature": time_sig,
281 "mood": mood,
282 "genre": genre,
283 "reference_artist": reference or None,
284 "duration_bars": duration_bars,
285 "modulation": modulate,
286 "primary_chords": chords,
287 "harmonic_tension_profile": tension_profile,
288 "texture_guidance": texture,
289 "sections": sections,
290 "workflow": [
291 "1. Create a new Muse project with these settings.",
292 f"2. Start with a {intro_bars}-bar intro using {chords[0]} → {chords[2]}.",
293 "3. Build each section in order; use musehub_create_issue per section.",
294 "4. Commit after each section: 'feat: add [section] in [key]'.",
295 "5. Open a PR for review once the full structure is in place.",
296 ],
297 }
298 }
299
300
301 # ── Interactive PR review ─────────────────────────────────────────────────────
302
303
304 async def execute_review_pr_interactive(
305 repo_id: str,
306 pr_id: str,
307 *,
308 dimension: str | None = None,
309 depth: str | None = None,
310 ctx: "ToolCallContext",
311 ) -> MusehubToolResult:
312 """Review a PR interactively by eliciting the reviewer's focus and depth.
313
314 Bypass path (no session needed): supply ``dimension`` and ``depth`` directly
315 to run the divergence analysis without any elicitation round-trip.
316
317 Elicitation path (session required): when bypass params are omitted and an
318 active session exists, a form collects dimension focus, review depth, and
319 optional reviewer notes.
320
321 Collects: dimension focus (melodic / harmonic / rhythmic / structural /
322 dynamic / all), review depth (quick / standard / thorough).
323
324 Args:
325 repo_id: Repository ID containing the PR.
326 pr_id: Pull request ID to review.
327 dimension: Bypass param — one of: melodic, harmonic, rhythmic, structural, dynamic, all.
328 depth: Bypass param — one of: quick, standard, thorough.
329 ctx: Tool call context (session required only when bypass params are absent).
330 """
331 check_harmonic = True
332 check_rhythmic = True
333 note = ""
334
335 # ── Bypass: dimension + depth provided directly ───────────────────────────
336 if dimension is not None or depth is not None:
337 dimension = dimension or "all"
338 depth = depth or "standard"
339 elif not ctx.has_session:
340 # No session and no bypass params — return actionable guide.
341 return MusehubToolResult(
342 ok=True,
343 data={
344 "mode": "schema_guide",
345 "message": (
346 "No active MCP session. Pass dimension and depth to bypass elicitation: "
347 "musehub_review_pr_interactive(repo_id=..., pr_id=..., dimension='all', depth='standard')"
348 ),
349 "dimension_options": ["melodic", "harmonic", "rhythmic", "structural", "dynamic", "all"],
350 "depth_options": ["quick", "standard", "thorough"],
351 },
352 )
353 else:
354 # ── Elicitation path: active session ──────────────────────────────────
355 prefs = await ctx.elicit_form(
356 SCHEMAS["pr_review_focus"],
357 message=(
358 f"I'll review PR {pr_id[:8]}. How would you like me to focus the review? "
359 "Choose a musical dimension and depth level."
360 ),
361 )
362
363 if prefs is None:
364 return MusehubToolResult(
365 ok=False,
366 error_code="elicitation_declined",
367 error_message="User declined the PR review focus form.",
368 )
369
370 dimension = str(prefs.get("dimension_focus", "all"))
371 depth = str(prefs.get("review_depth", "standard"))
372 check_harmonic = bool(prefs.get("check_harmonic_tension", True))
373 check_rhythmic = bool(prefs.get("check_rhythmic_consistency", True))
374 note = str(prefs.get("reviewer_note", ""))
375
376 await ctx.progress("review", 0, 3, f"Analysing PR {pr_id[:8]}… ({dimension} / {depth})")
377
378 # Run the divergence analysis via the existing executor.
379 from musehub.services.musehub_mcp_executor import _check_db_available
380 from musehub.db.database import AsyncSessionLocal
381 from musehub.services import musehub_pull_requests, musehub_divergence
382
383 db_ok = _check_db_available()
384 if db_ok is not None:
385 return MusehubToolResult(
386 ok=False,
387 error_code="db_unavailable",
388 error_message="Database unavailable.",
389 )
390
391 async with AsyncSessionLocal() as db:
392 pr = await musehub_pull_requests.get_pr(db, repo_id, pr_id)
393 if pr is None:
394 return MusehubToolResult(
395 ok=False,
396 error_code="not_found",
397 error_message=f"PR {pr_id} not found in repo {repo_id}.",
398 )
399
400 await ctx.progress("review", 1, 3, "Computing branch divergence…")
401
402 try:
403 div_result = await musehub_divergence.compute_hub_divergence(
404 db, repo_id=repo_id, branch_a=pr.from_branch, branch_b=pr.to_branch
405 )
406 except ValueError as e:
407 div_result = None
408 div_error = str(e)
409
410 await ctx.progress("review", 2, 3, "Building review report…")
411
412 review: dict[str, JSONValue] = {
413 "pr_id": pr_id,
414 "repo_id": repo_id,
415 "from_branch": pr.from_branch,
416 "to_branch": pr.to_branch,
417 "focus_dimension": dimension,
418 "depth": depth,
419 "reviewer_note": note or None,
420 }
421
422 if div_result:
423 dims: list[JSONValue] = [
424 {
425 "dimension": d.dimension,
426 "score": d.score,
427 "level": d.level.value,
428 "description": d.description,
429 }
430 for d in div_result.dimensions
431 if dimension == "all" or d.dimension == dimension
432 ]
433 review["overall_score"] = div_result.overall_score
434 review["common_ancestor"] = div_result.common_ancestor
435 review["dimensions"] = dims
436
437 findings: list[str] = []
438 for d in div_result.dimensions:
439 if d.score > 0.7 and (dimension == "all" or d.dimension == dimension):
440 findings.append(
441 f"⚠️ HIGH {d.dimension} divergence ({d.score:.0%}): {d.description}"
442 )
443 elif d.score > 0.4 and depth == "thorough":
444 findings.append(
445 f"ℹ️ Moderate {d.dimension} divergence ({d.score:.0%}): {d.description}"
446 )
447 if check_harmonic and div_result.overall_score > 0.5:
448 findings.append(
449 "🎵 Harmonic tension check: significant harmonic changes detected — "
450 "verify voice-leading and resolution in the bridge/chorus."
451 )
452 if check_rhythmic:
453 rhythmic = next((d for d in div_result.dimensions if d.dimension == "rhythmic"), None)
454 if rhythmic and rhythmic.score > 0.3:
455 findings.append(
456 f"🥁 Rhythmic consistency: score {rhythmic.score:.0%} — "
457 "check for tempo drift or conflicting groove patterns."
458 )
459 review["findings"] = list(findings) if findings else ["No significant issues detected."]
460 review["recommendation"] = (
461 "APPROVE" if div_result.overall_score < 0.3
462 else "REQUEST_CHANGES" if div_result.overall_score > 0.6
463 else "COMMENT"
464 )
465 else:
466 review["divergence_error"] = div_error if "div_error" in dir() else "unknown"
467 review["recommendation"] = "COMMENT"
468 review["findings"] = ["Could not compute divergence — check that both branches have commits."]
469
470 await ctx.progress("review", 3, 3, "Review complete.")
471 return MusehubToolResult(ok=True, data=review)
472
473
474 # ── Connect streaming platform ────────────────────────────────────────────────
475
476
477 async def execute_connect_streaming_platform(
478 platform: str | None,
479 repo_id: str | None,
480 *,
481 ctx: "ToolCallContext",
482 ) -> MusehubToolResult:
483 """Connect a streaming platform account via URL-mode elicitation (OAuth).
484
485 Directs the user to a MuseHub OAuth start page for the chosen platform.
486 Once the OAuth flow completes, the agent can use the connection to
487 distribute Muse releases directly to the platform.
488
489 Supported platforms: Spotify, SoundCloud, Bandcamp, YouTube Music,
490 Apple Music, TIDAL, Amazon Music, Deezer.
491
492 Args:
493 platform: Target platform name (optional; elicited if not provided).
494 repo_id: Optional repository context for release distribution.
495 ctx: Tool call context (must have active session for URL elicitation).
496 """
497 # ── Bypass: platform known + no session → return OAuth URL for manual use ──
498 if platform and platform in AVAILABLE_PLATFORMS and not ctx.has_session:
499 import secrets as _secrets
500 elicitation_id = _secrets.token_urlsafe(16)
501 connect_url = oauth_connect_url(platform, elicitation_id)
502 return MusehubToolResult(
503 ok=True,
504 data={
505 "platform": platform,
506 "status": "pending_oauth",
507 "oauth_url": connect_url,
508 "elicitation_id": elicitation_id,
509 "message": (
510 f"Open this URL to connect {platform} to MuseHub. "
511 "No active MCP session detected — manual browser navigation required."
512 ),
513 },
514 )
515
516 if not ctx.has_session:
517 return MusehubToolResult(
518 ok=True,
519 data={
520 "mode": "schema_guide",
521 "message": (
522 "No active MCP session. Pass platform directly to get an OAuth URL: "
523 "musehub_connect_streaming_platform(platform='Spotify')"
524 ),
525 "platform_options": AVAILABLE_PLATFORMS,
526 },
527 )
528
529 # If platform not supplied as argument, elicit it.
530 if not platform or platform not in AVAILABLE_PLATFORMS:
531 prefs = await ctx.elicit_form(
532 SCHEMAS["platform_connect_confirm"],
533 message=(
534 "Which streaming platform would you like to connect? "
535 "I'll redirect you to their OAuth page to authorise MuseHub."
536 ),
537 )
538 if prefs is None:
539 return MusehubToolResult(
540 ok=False,
541 error_code="elicitation_declined",
542 error_message="User declined the platform selection form.",
543 )
544 platform = str(prefs.get("platform", ""))
545 confirmed = bool(prefs.get("confirm", False))
546 if not confirmed:
547 return MusehubToolResult(
548 ok=False,
549 error_code="not_confirmed",
550 error_message="User did not confirm the platform connection.",
551 )
552
553 import secrets
554 elicitation_id = secrets.token_urlsafe(16)
555 connect_url = oauth_connect_url(platform, elicitation_id)
556
557 accepted = await ctx.elicit_url(
558 connect_url,
559 message=(
560 f"To connect {platform}, please authorise MuseHub on the {platform} website. "
561 f"Your browser will open to the MuseHub → {platform} OAuth page."
562 ),
563 elicitation_id=elicitation_id,
564 )
565
566 if not accepted:
567 return MusehubToolResult(
568 ok=False,
569 error_code="elicitation_declined",
570 error_message=f"User declined or cancelled the {platform} OAuth flow.",
571 )
572
573 return MusehubToolResult(
574 ok=True,
575 data={
576 "platform": platform,
577 "status": "connected",
578 "elicitation_id": elicitation_id,
579 "message": (
580 f"{platform} connection initiated. Once the OAuth flow completes in your "
581 f"browser, you can use musehub_create_release_interactive to distribute "
582 f"releases to {platform}."
583 ),
584 "next_steps": [
585 f"Call musehub_create_release_interactive with repo_id={repo_id or '<repo_id>'} "
586 f"to publish your next release to {platform}.",
587 ],
588 },
589 )
590
591
592 # ── Connect cloud DAW ─────────────────────────────────────────────────────────
593
594
595 async def execute_connect_daw_cloud(
596 service: str | None,
597 *,
598 ctx: "ToolCallContext",
599 ) -> MusehubToolResult:
600 """Connect a cloud DAW / mastering service via URL-mode elicitation (OAuth).
601
602 Enables agents to trigger cloud renders, stems exports, and mastering jobs
603 directly from MuseHub. Supported services: LANDR, Splice, Soundtrap,
604 BandLab, Audiotool.
605
606 Args:
607 service: Target cloud DAW service name (optional; elicited if absent).
608 ctx: Tool call context (must have active session for URL elicitation).
609 """
610 # ── Bypass: service known + no session → return OAuth URL for manual use ───
611 if service and service in AVAILABLE_DAW_CLOUDS and not ctx.has_session:
612 import secrets as _secrets
613 elicitation_id = _secrets.token_urlsafe(16)
614 connect_url = daw_cloud_connect_url(service, elicitation_id)
615 return MusehubToolResult(
616 ok=True,
617 data={
618 "service": service,
619 "status": "pending_oauth",
620 "oauth_url": connect_url,
621 "elicitation_id": elicitation_id,
622 "capabilities": _daw_capabilities(service),
623 "message": (
624 f"Open this URL to connect {service} to MuseHub. "
625 "No active MCP session — manual browser navigation required."
626 ),
627 },
628 )
629
630 if not ctx.has_session:
631 return MusehubToolResult(
632 ok=True,
633 data={
634 "mode": "schema_guide",
635 "message": (
636 "No active MCP session. Pass service directly to get an OAuth URL: "
637 "musehub_connect_daw_cloud(service='LANDR')"
638 ),
639 "service_options": AVAILABLE_DAW_CLOUDS,
640 },
641 )
642
643 if not service or service not in AVAILABLE_DAW_CLOUDS:
644 # Elicit the service choice via a simple form.
645 schema: dict[str, JSONValue] = {
646 "type": "object",
647 "properties": {
648 "service": {
649 "type": "string",
650 "title": "Cloud DAW / Mastering Service",
651 "description": "Which service would you like to connect?",
652 "enum": AVAILABLE_DAW_CLOUDS,
653 "default": "LANDR",
654 },
655 "confirm": {
656 "type": "boolean",
657 "title": "Confirm Connection",
658 "description": "I understand this will redirect me to the service's OAuth page",
659 "default": False,
660 },
661 },
662 "required": ["service", "confirm"],
663 }
664 prefs = await ctx.elicit_form(
665 schema,
666 message=(
667 "Which cloud DAW or mastering service would you like to connect? "
668 "Once connected, I can trigger renders, exports, and mastering jobs automatically."
669 ),
670 )
671 if prefs is None:
672 return MusehubToolResult(
673 ok=False,
674 error_code="elicitation_declined",
675 error_message="User declined the DAW service selection.",
676 )
677 service = str(prefs.get("service", ""))
678 if not bool(prefs.get("confirm", False)):
679 return MusehubToolResult(
680 ok=False,
681 error_code="not_confirmed",
682 error_message="User did not confirm the service connection.",
683 )
684
685 import secrets
686 elicitation_id = secrets.token_urlsafe(16)
687 connect_url = daw_cloud_connect_url(service, elicitation_id)
688
689 accepted = await ctx.elicit_url(
690 connect_url,
691 message=(
692 f"Authorise MuseHub to connect with {service}. "
693 f"Your browser will open the MuseHub → {service} integration page."
694 ),
695 elicitation_id=elicitation_id,
696 )
697
698 if not accepted:
699 return MusehubToolResult(
700 ok=False,
701 error_code="elicitation_declined",
702 error_message=f"User declined the {service} OAuth flow.",
703 )
704
705 return MusehubToolResult(
706 ok=True,
707 data={
708 "service": service,
709 "status": "connected",
710 "elicitation_id": elicitation_id,
711 "capabilities": _daw_capabilities(service),
712 "message": (
713 f"{service} connected! You can now trigger cloud operations "
714 f"directly from MuseHub agent workflows."
715 ),
716 },
717 )
718
719
720 def _daw_capabilities(service: str) -> list[JSONValue]:
721 caps: dict[str, list[JSONValue]] = {
722 "LANDR": ["AI mastering", "stems export", "distribution to 150+ platforms"],
723 "Splice": ["sample sync", "project backup", "stem download"],
724 "Soundtrap": ["browser-based DAW", "real-time collaboration", "podcast tools"],
725 "BandLab": ["free mastering", "social sharing", "version history"],
726 "Audiotool": ["browser-based DAW", "sample library", "community publish"],
727 }
728 return caps.get(service, ["cloud integration"])
729
730
731 # ── Interactive release creation ──────────────────────────────────────────────
732
733
734 async def execute_create_release_interactive(
735 repo_id: str,
736 *,
737 tag: str | None = None,
738 title: str | None = None,
739 notes: str | None = None,
740 ctx: "ToolCallContext",
741 ) -> MusehubToolResult:
742 """Create a release interactively or directly via bypass params.
743
744 Bypass path (no session needed): supply ``tag`` (required), plus optional
745 ``title`` and ``notes`` to create the release without any elicitation.
746
747 Elicitation path (session required): when bypass params are omitted and an
748 active MCP session exists, a form collects tag, title, release notes, and
749 pre-release flag, followed by an optional Spotify OAuth URL prompt.
750
751 No-session, no-params path: returns a schema guide listing every field.
752
753 Args:
754 repo_id: Repository to tag the release against.
755 tag: Bypass param — semantic version tag (e.g. "v1.2.0").
756 title: Bypass param — human-readable release title.
757 notes: Bypass param — release notes / changelog body.
758 ctx: Tool call context (session required only when bypass params absent).
759 """
760 highlight = ""
761
762 # ── Bypass: tag provided directly ────────────────────────────────────────
763 if tag is not None:
764 resolved_title = title or tag
765 release_notes = notes or ""
766 await ctx.progress("release", 0, 2, f"Creating release {tag}…")
767
768 from musehub.mcp.write_tools.releases import execute_create_release
769
770 result = await execute_create_release(
771 repo_id=repo_id,
772 tag=tag,
773 title=resolved_title,
774 body=release_notes,
775 commit_id=None,
776 channel="stable",
777 actor=ctx.user_id or "",
778 )
779
780 await ctx.progress("release", 2, 2, "Done.")
781
782 if not result.ok:
783 return result
784
785 release_data = result.data or {}
786 return MusehubToolResult(
787 ok=True,
788 data={
789 **release_data,
790 "workflow_hint": (
791 "Release published! Call musehub_connect_streaming_platform to "
792 "distribute to Spotify, SoundCloud, Bandcamp, and more."
793 ),
794 },
795 )
796
797 # ── No session and no bypass params: return field guide ──────────────────
798 if not ctx.has_session:
799 return MusehubToolResult(
800 ok=True,
801 data={
802 "mode": "schema_guide",
803 "message": (
804 "No active MCP session. Pass 'tag' to bypass elicitation: "
805 "musehub_create_release_interactive(repo_id=..., tag='v1.0.0', title='...', notes='...')"
806 ),
807 "fields": {
808 "tag": {"type": "string", "required": True, "example": "v1.0.0"},
809 "title": {"type": "string", "required": False, "example": "First release"},
810 "notes": {"type": "string", "required": False, "example": "Bug fixes and improvements"},
811 },
812 },
813 )
814
815 # ── Elicitation path: active session ──────────────────────────────────────
816 await ctx.progress("release", 0, 3, "Collecting release metadata…")
817
818 # Step 1: collect release metadata via form elicitation.
819 prefs = await ctx.elicit_form(
820 SCHEMAS["release_metadata"],
821 message=(
822 f"Let's create a release for repo {repo_id}. "
823 "Fill in the release details below."
824 ),
825 )
826
827 if prefs is None:
828 return MusehubToolResult(
829 ok=False,
830 error_code="elicitation_declined",
831 error_message="User declined the release metadata form.",
832 )
833
834 tag = str(prefs.get("tag", "v1.0.0"))
835 resolved_title = str(prefs.get("title", tag))
836 release_notes = str(prefs.get("release_notes", ""))
837 channel_raw = prefs.get("channel", "stable")
838 channel = str(channel_raw) if isinstance(channel_raw, str) else "stable"
839 highlight = str(prefs.get("highlight", ""))
840
841 if highlight:
842 release_notes = f"**Highlight:** {highlight}\n\n{release_notes}".strip()
843
844 await ctx.progress("release", 1, 3, f"Creating release {tag}…")
845
846 # Create the release using the existing executor.
847 from musehub.mcp.write_tools.releases import execute_create_release
848
849 result = await execute_create_release(
850 repo_id=repo_id,
851 tag=tag,
852 title=resolved_title,
853 body=release_notes,
854 commit_id=None,
855 channel=channel,
856 actor=ctx.user_id or "",
857 )
858
859 if not result.ok:
860 return result
861
862 await ctx.progress("release", 2, 3, "Release created. Checking platform connections…")
863
864 # Step 2: offer streaming platform connection (URL elicitation), non-blocking.
865 if ctx.has_session and ctx.session and ctx.session.supports_elicitation_url():
866 import secrets
867 elicitation_id = secrets.token_urlsafe(16)
868 spotify_url = oauth_connect_url("Spotify", elicitation_id)
869
870 # Non-blocking: just offer it — tool returns success either way.
871 await ctx.elicit_url(
872 spotify_url,
873 message=(
874 "Release created! Would you like to distribute it to Spotify? "
875 "Click through to connect your Spotify for Artists account."
876 ),
877 elicitation_id=elicitation_id,
878 )
879
880 release_data = result.data or {}
881 release_data["highlight"] = highlight or None
882
883 await ctx.progress("release", 3, 3, "Done.")
884
885 return MusehubToolResult(
886 ok=True,
887 data={
888 **release_data,
889 "workflow_hint": (
890 "Release published! Call musehub_connect_streaming_platform to "
891 "distribute to Spotify, SoundCloud, Bandcamp, and more."
892 ),
893 },
894 )