"""Elicitation-powered tool executors for MCP 2025-11-25. These tools use ``ToolCallContext.elicit_form()`` and ``elicit_url()`` to collect structured input from users mid-tool-call. They require an active session (``Mcp-Session-Id``) and a client that has declared elicitation capability. When running without a session they degrade gracefully. Tools in this module: musehub_review_proposal_interactive Form-mode: collects dimension focus and review depth before running a deep musical divergence analysis of the proposal. musehub_create_release_interactive Form-mode: collects release metadata interactively, then creates the release. """ import logging from typing import TYPE_CHECKING from musehub.types.json_types import JSONObject, JSONValue from musehub.mcp.elicitation import SCHEMAS from musehub.services.musehub_mcp_executor import MusehubToolResult, _check_db_available if TYPE_CHECKING: from musehub.mcp.context import ToolCallContext logger = logging.getLogger(__name__) # ── Interactive proposal review ─────────────────────────────────────────────── async def execute_review_proposal_interactive( repo_id: str, proposal_id: str, *, dimension: str | None = None, depth: str | None = None, ctx: "ToolCallContext", ) -> MusehubToolResult: """Review a proposal interactively by eliciting the reviewer's focus and depth. Bypass path (no session needed): supply ``dimension`` and ``depth`` directly to run the divergence analysis without any elicitation round-trip. Elicitation path (session required): when bypass params are omitted and an active session exists, a form collects dimension focus, review depth, and optional reviewer notes. Collects: dimension focus (melodic / harmonic / rhythmic / structural / dynamic / all), review depth (quick / standard / thorough). Args: repo_id: Repository ID containing the proposal. proposal_id: Proposal ID to review. dimension: Bypass param — one of: melodic, harmonic, rhythmic, structural, dynamic, all. depth: Bypass param — one of: quick, standard, thorough. ctx: Tool call context (session required only when bypass params are absent). """ check_harmonic = True check_rhythmic = True note = "" # ── Bypass: dimension + depth provided directly ─────────────────────────── if dimension is not None or depth is not None: dimension = dimension or "all" depth = depth or "standard" elif not ctx.has_session: # No session and no bypass params — return actionable guide. return MusehubToolResult( ok=True, data={ "mode": "schema_guide", "message": ( "No active MCP session. Pass dimension and depth to bypass elicitation: " "musehub_review_proposal_interactive(repo_id=..., proposal_id=..., dimension='all', depth='standard')" ), "dimension_options": ["melodic", "harmonic", "rhythmic", "structural", "dynamic", "all"], "depth_options": ["quick", "standard", "thorough"], }, ) else: # ── Elicitation path: active session ────────────────────────────────── prefs = await ctx.elicit_form( SCHEMAS["proposal_review_focus"], message=( f"I'll review proposal {proposal_id}. How would you like me to focus the review? " "Choose a musical dimension and depth level." ), ) if prefs is None: return MusehubToolResult( ok=False, error_code="elicitation_declined", error_message="User declined the proposal review focus form.", ) dimension = str(prefs.get("dimension_focus", "all")) depth = str(prefs.get("review_depth", "standard")) check_harmonic = bool(prefs.get("check_harmonic_tension", True)) check_rhythmic = bool(prefs.get("check_rhythmic_consistency", True)) note = str(prefs.get("reviewer_note", "")) await ctx.progress("review", 0, 3, f"Analysing proposal {proposal_id}… ({dimension} / {depth})") # Run the divergence analysis via the existing executor. from musehub.services.musehub_mcp_executor import _check_db_available from musehub.db.database import AsyncSessionLocal from musehub.services import musehub_proposals, musehub_divergence db_ok = _check_db_available() if db_ok is not None: return MusehubToolResult( ok=False, error_code="db_unavailable", error_message="Database unavailable.", ) async with AsyncSessionLocal() as db: proposal = await musehub_proposals.get_proposal(db, repo_id, proposal_id) if proposal is None: return MusehubToolResult( ok=False, error_code="proposal_not_found", error_message=f"Proposal {proposal_id} not found in repo {repo_id}.", hint="Call musehub_list_proposals() to see open proposals.", ) await ctx.progress("review", 1, 3, "Computing branch divergence…") try: div_result = await musehub_divergence.compute_hub_divergence( db, repo_id=repo_id, branch_a=proposal.from_branch, branch_b=proposal.to_branch ) except ValueError as e: div_result = None div_error = str(e) await ctx.progress("review", 2, 3, "Building review report…") review: JSONObject = { "proposal_id": proposal_id, "repo_id": repo_id, "from_branch": proposal.from_branch, "to_branch": proposal.to_branch, "focus_dimension": dimension, "depth": depth, "reviewer_note": note or None, } if div_result: dims: list[JSONValue] = [ { "dimension": d.dimension, "score": d.score, "level": d.level.value, "description": d.description, } for d in div_result.dimensions if dimension == "all" or d.dimension == dimension ] review["overall_score"] = div_result.overall_score review["common_ancestor"] = div_result.common_ancestor review["dimensions"] = dims findings: list[str] = [] for d in div_result.dimensions: if d.score > 0.7 and (dimension == "all" or d.dimension == dimension): findings.append( f"⚠️ HIGH {d.dimension} divergence ({d.score:.0%}): {d.description}" ) elif d.score > 0.4 and depth == "thorough": findings.append( f"ℹ️ Moderate {d.dimension} divergence ({d.score:.0%}): {d.description}" ) if check_harmonic and div_result.overall_score > 0.5: findings.append( "🎵 Harmonic tension check: significant harmonic changes detected — " "verify voice-leading and resolution in the bridge/chorus." ) if check_rhythmic: rhythmic = next((d for d in div_result.dimensions if d.dimension == "rhythmic"), None) if rhythmic and rhythmic.score > 0.3: findings.append( f"🥁 Rhythmic consistency: score {rhythmic.score:.0%} — " "check for tempo drift or conflicting groove patterns." ) review["findings"] = list(findings) if findings else ["No significant issues detected."] review["recommendation"] = ( "APPROVE" if div_result.overall_score < 0.3 else "REQUEST_CHANGES" if div_result.overall_score > 0.6 else "COMMENT" ) else: review["divergence_error"] = div_error if "div_error" in dir() else "unknown" review["recommendation"] = "COMMENT" review["findings"] = ["Could not compute divergence — check that both branches have commits."] await ctx.progress("review", 3, 3, "Review complete.") return MusehubToolResult(ok=True, data=review) # ── Interactive release creation ────────────────────────────────────────────── async def execute_create_release_interactive( repo_id: str, *, tag: str | None = None, title: str | None = None, notes: str | None = None, ctx: "ToolCallContext", ) -> MusehubToolResult: """Create a release interactively or directly via bypass params. Bypass path (no session needed): supply ``tag`` (required), plus optional ``title`` and ``notes`` to create the release without any elicitation. Elicitation path (session required): when bypass params are omitted and an active MCP session exists, a form collects tag, title, release notes, and pre-release flag, followed by an optional Spotify OAuth URL prompt. No-session, no-params path: returns a schema guide listing every field. Args: repo_id: Repository to tag the release against. tag: Bypass param — semantic version tag (e.g. "v1.2.0"). title: Bypass param — human-readable release title. notes: Bypass param — release notes / changelog body. ctx: Tool call context (session required only when bypass params absent). """ highlight = "" # ── Bypass: tag provided directly ──────────────────────────────────────── if tag is not None: resolved_title = title or tag release_notes = notes or "" await ctx.progress("release", 0, 2, f"Creating release {tag}…") from musehub.mcp.write_tools.releases import execute_create_release result = await execute_create_release( repo_id=repo_id, tag=tag, title=resolved_title, body=release_notes, commit_id=None, channel="stable", actor=ctx.user_id or "", ) await ctx.progress("release", 2, 2, "Done.") if not result.ok: return result release_data = result.data or {} return MusehubToolResult( ok=True, data={ **release_data, "workflow_hint": ( "Release published. Use musehub_create_release_interactive for a guided release flow." ), }, ) # ── No session and no bypass params: return field guide ────────────────── if not ctx.has_session: return MusehubToolResult( ok=True, data={ "mode": "schema_guide", "message": ( "No active MCP session. Pass 'tag' to bypass elicitation: " "musehub_create_release_interactive(repo_id=..., tag='v1.0.0', title='...', notes='...')" ), "fields": { "tag": {"type": "string", "required": True, "example": "v1.0.0"}, "title": {"type": "string", "required": False, "example": "First release"}, "notes": {"type": "string", "required": False, "example": "Bug fixes and improvements"}, }, }, ) # ── Elicitation path: active session ────────────────────────────────────── await ctx.progress("release", 0, 3, "Collecting release metadata…") # Step 1: collect release metadata via form elicitation. prefs = await ctx.elicit_form( SCHEMAS["release_metadata"], message=( f"Let's create a release for repo {repo_id}. " "Fill in the release details below." ), ) if prefs is None: return MusehubToolResult( ok=False, error_code="elicitation_declined", error_message="User declined the release metadata form.", ) tag = str(prefs.get("tag", "v1.0.0")) resolved_title = str(prefs.get("title", tag)) release_notes = str(prefs.get("release_notes", "")) channel_raw = prefs.get("channel", "stable") channel = str(channel_raw) if isinstance(channel_raw, str) else "stable" highlight = str(prefs.get("highlight", "")) if highlight: release_notes = f"**Highlight:** {highlight}\n\n{release_notes}".strip() await ctx.progress("release", 1, 3, f"Creating release {tag}…") # Create the release using the existing executor. from musehub.mcp.write_tools.releases import execute_create_release result = await execute_create_release( repo_id=repo_id, tag=tag, title=resolved_title, body=release_notes, commit_id=None, channel=channel, actor=ctx.user_id or "", ) if not result.ok: return result await ctx.progress("release", 2, 3, "Release created. Checking platform connections…") # Step 2: offer streaming platform connection (URL elicitation), non-blocking. if ctx.has_session and ctx.session and ctx.session.supports_elicitation_url(): import secrets elicitation_id = secrets.token_urlsafe(16) spotify_url = oauth_connect_url("Spotify", elicitation_id) # Non-blocking: just offer it — tool returns success either way. await ctx.elicit_url( spotify_url, message=( "Release created! Would you like to distribute it to Spotify? " "Click through to connect your Spotify for Artists account." ), elicitation_id=elicitation_id, ) release_data = result.data or {} release_data["highlight"] = highlight or None await ctx.progress("release", 3, 3, "Done.") return MusehubToolResult( ok=True, data={ **release_data, "workflow_hint": ( "Release published. Use musehub_create_release_interactive for a guided release flow." ), }, )