gabriel / musehub public
dispatcher.py python
1,685 lines 71.2 KB
Raw
sha256:50b52eda7afb2f122863aef47d684d1a9e4684b48f5f95367fc956e28ceb7d42 refactor: rename merge strategy aliases to canonical names Sonnet 4.6 minor ⚠ breaking 10 days ago
1 """MuseHub MCP Dispatcher — async JSON-RPC 2.0 engine (MCP 2025-11-25).
2
3 This is the protocol core: it receives a parsed JSON-RPC 2.0 message dict
4 and returns the appropriate JSON-RPC 2.0 response dict.
5
6 Supported methods:
7 initialize → server capabilities handshake (2025-11-25)
8 tools/list → full tool catalogue (27 standard + 5 elicitation-powered)
9 tools/call → route to read, write, or elicitation-powered executor
10 resources/list → static resource catalogue
11 resources/templates/list → RFC 6570 URI templates
12 resources/read → musehub:// URI dispatcher
13 prompts/list → prompt catalogue
14 prompts/get → assembled prompt messages
15 notifications/cancelled → cancel pending elicitation Futures
16 notifications/elicitation/complete → resolve URL-mode elicitation Futures
17 ping → liveness check
18
19 Design principles (from agentception):
20 - JSON-RPC envelope is always success (200 OK / no envelope error).
21 - Tool errors are signalled via ``isError: true`` on the content block.
22 - No external MCP SDK dependency — pure Python async.
23 - All DB access happens inside executor/resource functions, never here.
24 - Notifications (no ``id`` field) return None — callers return 202.
25 - Session context is optional; tools without elicitation work stateless.
26 """
27
28 import json
29 import logging
30 from typing import TYPE_CHECKING
31
32 from typing import TypedDict
33
34 from musehub.types.json_types import IntDict, JSONObject, JSONValue, StrDict
35
36 class _SymbolIndexItem(TypedDict):
37 """One entry in the symbol index listing returned by ``musehub_list_symbols``."""
38
39 address: str
40 op: str | None
41 content_id: str | None
42 last_commit_id: str | None
43 last_modified: str | None
44 history_count: int
45 from musehub.types.mcp_types import (
46 MCPContentBlock,
47 MCPErrorDetail,
48 MCPErrorResponse,
49 MCPRequest,
50 MCPSuccessResponse,
51 MCPToolDef,
52 )
53 from musehub.mcp.prompts import PROMPT_CATALOGUE, PROMPT_NAMES, get_prompt
54 from musehub.mcp.resources import (
55 RESOURCE_TEMPLATES,
56 STATIC_RESOURCES,
57 read_resource,
58 )
59 from musehub.mcp.tools import MCP_TOOLS, MUSEHUB_WRITE_TOOL_NAMES
60
61 if TYPE_CHECKING:
62 from musehub.mcp.context import ToolCallContext
63 from musehub.mcp.session import MCPSession
64
65 logger = logging.getLogger(__name__)
66
67 _PROTOCOL_VERSION = "2025-11-25"
68 _SERVER_NAME = "musehub-mcp"
69 _SERVER_VERSION = "0.2.0"
70
71 # JSON-RPC 2.0 error codes
72 _PARSE_ERROR = -32700
73 _INVALID_REQUEST = -32600
74 _METHOD_NOT_FOUND = -32601
75 _INVALID_PARAMS = -32602
76 _INTERNAL_ERROR = -32603
77 _UNAUTHORIZED = -32000 # Authentication required (application-level sentinel)
78 _URL_ELICITATION_REQUIRED = -32042 # MCP 2025-11-25 new error code
79
80 # ── Public entry points ───────────────────────────────────────────────────────
81
82 async def handle_request(
83 raw: JSONObject,
84 *,
85 user_id: str | None = None,
86 session: "MCPSession | None" = None,
87 is_agent: bool = False,
88 agent_name: str | None = None,
89 ) -> JSONObject | None:
90 """Dispatch a single JSON-RPC 2.0 request and return the response dict.
91
92 Args:
93 raw: Parsed JSON-RPC 2.0 request dict.
94 user_id: Authenticated user ID from MSign handle (``None`` for anonymous).
95 session: Active MCP session for elicitation and progress features,
96 or ``None`` for stateless (non-elicitation) clients.
97 is_agent: ``True`` when the caller presents an agent Ed25519 identity.
98 agent_name: Optional display name of the agent (from ``agent_name`` claim).
99
100 Returns:
101 JSON-serialisable response dict, or ``None`` for notifications
102 (requests without an ``id`` field).
103 """
104 _raw_id = raw.get("id")
105 req_id: str | int | None = _raw_id if isinstance(_raw_id, (str, int)) else None
106 method = raw.get("method")
107
108 if not isinstance(method, str):
109 return _error(req_id, _INVALID_REQUEST, "Missing or invalid 'method' field")
110
111 # Notifications (no id) are fire-and-forget — return None.
112 is_notification = "id" not in raw
113
114 raw_params = raw.get("params")
115 params: JSONObject = raw_params if isinstance(raw_params, dict) else {}
116
117 try:
118 result = await _dispatch(
119 method, params, user_id=user_id, session=session,
120 is_agent=is_agent, agent_name=agent_name,
121 )
122 if is_notification:
123 return None
124 return _success(req_id, result)
125 except _MCPError as exc:
126 if is_notification:
127 return None
128 return _error(req_id, exc.code, exc.message, exc.data)
129 except Exception as exc:
130 logger.exception("Unhandled error in MCP dispatcher (method=%s): %s", method, exc)
131 if is_notification:
132 return None
133 # Do not propagate exc details to the client — they may contain
134 # stack traces, DB query fragments, or internal paths.
135 return _error(req_id, _INTERNAL_ERROR, "Internal error")
136
137 async def handle_batch(
138 requests: list[JSONObject],
139 *,
140 user_id: str | None = None,
141 session: "MCPSession | None" = None,
142 is_agent: bool = False,
143 agent_name: str | None = None,
144 ) -> list[JSONObject]:
145 """Dispatch a JSON-RPC 2.0 batch and return all non-notification responses.
146
147 Args:
148 requests: List of parsed JSON-RPC 2.0 request dicts.
149 user_id: Authenticated user ID (``None`` for anonymous).
150 session: Active MCP session, or ``None`` for stateless clients.
151 is_agent: ``True`` when the caller presents an agent Ed25519 identity.
152 agent_name: Optional display name of the agent.
153
154 Returns:
155 List of response dicts (excluding None responses for notifications).
156 """
157 results: list[JSONObject] = []
158 for req in requests:
159 resp = await handle_request(
160 req, user_id=user_id, session=session,
161 is_agent=is_agent, agent_name=agent_name,
162 )
163 if resp is not None:
164 results.append(resp)
165 return results
166
167 # ── Internal dispatcher ───────────────────────────────────────────────────────
168
169 class _MCPError(Exception):
170 def __init__(self, code: int, message: str, data: JSONValue | None = None) -> None:
171 super().__init__(message)
172 self.code = code
173 self.message = message
174 self.data = data
175
176 async def _dispatch(
177 method: str,
178 params: JSONObject,
179 *,
180 user_id: str | None,
181 session: "MCPSession | None",
182 is_agent: bool = False,
183 agent_name: str | None = None,
184 ) -> JSONObject:
185 """Route a method name to its handler and return the result dict."""
186
187 if method == "initialize":
188 return _handle_initialize(params)
189
190 if method == "tools/list":
191 return _handle_tools_list()
192
193 if method == "tools/call":
194 return await _handle_tools_call(
195 params, user_id=user_id, session=session,
196 is_agent=is_agent, agent_name=agent_name,
197 )
198
199 if method == "resources/list":
200 return _handle_resources_list()
201
202 if method == "resources/templates/list":
203 return _handle_resources_templates_list()
204
205 if method == "resources/read":
206 return await _handle_resources_read(params, user_id=user_id)
207
208 if method == "prompts/list":
209 return _handle_prompts_list()
210
211 if method == "prompts/get":
212 return _handle_prompts_get(params)
213
214 # ── MCP 2025-11-25 notification methods ───────────────────────────────────
215
216 if method == "notifications/cancelled":
217 _handle_notifications_cancelled(params, session=session)
218 return {}
219
220 if method == "notifications/elicitation/complete":
221 _handle_elicitation_complete(params, session=session)
222 return {}
223
224 if method == "notifications/initialized":
225 # Client acknowledgement after initialize. Push a compact orientation
226 # summary to any already-open SSE queues (best-effort — silently
227 # dropped if no GET /mcp stream is open yet).
228 if session is not None and session.sse_queues:
229 from musehub.mcp.session import push_to_session
230 from musehub.mcp.sse import sse_notification
231 push_to_session(session, sse_notification("notifications/message", {
232 "level": "info",
233 "logger": "musehub",
234 "data": (
235 "Session ready. "
236 "Quick start: (1) musehub_set_context(owner, slug) to focus your session, "
237 "(2) musehub_read_context() to orient, "
238 "(3) musehub_read_coord_swarm() to survey active agents. "
239 "All errors include error_code + hint. "
240 "Full docs: musehub_read_prompt(name='musehub/orientation', "
241 "arguments={'caller_type': 'agent'})"
242 ),
243 }))
244 return {}
245
246 # ── MCP 2025-11-25 additional methods ────────────────────────────────────
247
248 if method == "completions/complete":
249 # Stub autocomplete — returns empty values; can be populated per-arg later.
250 return {"completion": {"values": [], "hasMore": False, "total": 0}}
251
252 if method == "logging/setLevel":
253 # M5: Only authenticated users may change the server log level.
254 # An anonymous caller could otherwise suppress all security-relevant logs.
255 if user_id is None:
256 raise _MCPError(_UNAUTHORIZED, "Authentication required for logging/setLevel")
257 level = params.get("level")
258 if isinstance(level, str):
259 import logging as _logging
260 _logging.getLogger("musehub").setLevel(level.upper())
261 return {}
262
263 # ── Standard methods ──────────────────────────────────────────────────────
264
265 if method == "ping":
266 return {}
267
268 raise _MCPError(_METHOD_NOT_FOUND, f"Method not found: {method!r}")
269
270 # ── Method handlers ───────────────────────────────────────────────────────────
271
272 def _handle_initialize(params: JSONObject) -> JSONObject:
273 """Return server capabilities and protocol version per MCP 2025-11-25.
274
275 Spec fix applied: ``serverInfo`` contains only ``name`` and ``version``.
276 Capabilities live exclusively at the top level of the result.
277 """
278 return {
279 "protocolVersion": _PROTOCOL_VERSION,
280 "serverInfo": {
281 "name": _SERVER_NAME,
282 "version": _SERVER_VERSION,
283 },
284 "capabilities": {
285 "tools": {"listChanged": False},
286 "resources": {"subscribe": False, "listChanged": False},
287 "prompts": {"listChanged": False},
288 "elicitation": {"form": {}, "url": {}},
289 "logging": {},
290 },
291 "instructions": (
292 "MuseHub MCP — Agent Quick Start\n\n"
293
294 "STEP 1 — FOCUS YOUR SESSION (do this first):\n"
295 " musehub_set_context(owner='<owner>', slug='<slug>')\n"
296 " All repo-scoped tools then inherit owner/slug automatically.\n"
297 " No need to pass owner+slug on every call.\n\n"
298
299 "STEP 2 — ORIENT:\n"
300 " musehub_read_context() → repo overview: domain, branches, commits, artifacts\n"
301 " musehub_read_coord_swarm() → who else is active, what tasks are in flight\n\n"
302
303 "STEP 3 — ERRORS ARE STRUCTURED:\n"
304 " Every error includes error_code (machine-readable) + hint (next step).\n"
305 " Branch on error_code, never parse error_message strings.\n"
306 " Key codes: repo_not_found, branch_not_found, issue_not_found,\n"
307 " proposal_not_found, symbol_not_found, unauthenticated, forbidden,\n"
308 " not_ready, missing_args, task_not_found.\n\n"
309
310 "STEP 4 — MULTI-AGENT COORDINATION:\n"
311 " Symbol editing protocol:\n"
312 " musehub_read_coord_conflicts(addresses=[...]) ← step 1: verify free\n"
313 " musehub_create_coord_reservation(addresses=[...], agent_id='...') ← step 2: lock\n"
314 " [edit, commit, push]\n"
315 " musehub_delete_coord_reservation(reservation_id='...') ← step 3: free\n"
316 " Task queue protocol:\n"
317 " musehub_enqueue_coord_task(queue, payload, agent_id) ← orchestrator\n"
318 " musehub_list_coord_tasks(status='pending') → musehub_claim_coord_task ← worker\n"
319 " musehub_complete_coord_task or musehub_fail_coord_task ← worker done\n"
320 " Agent signaling (real-time SSE):\n"
321 " musehub_agent_notify(target_handle, event, payload) ← direct\n"
322 " musehub_agent_broadcast(event, payload) ← all agents on same repo\n\n"
323
324 "FULL DOCS:\n"
325 " musehub_read_prompt(name='musehub/orientation', "
326 "arguments={'caller_type': 'agent'})"
327 ),
328 }
329
330 def _handle_tools_list() -> JSONObject:
331 """Return the full tool catalogue with MCP 2025-11-25 annotations injected."""
332 from musehub.mcp.tools.musehub import MUSEHUB_WRITE_TOOL_NAMES, MUSEHUB_ELICITATION_TOOL_NAMES
333
334 _READ_HINTS = {"readOnlyHint": True, "destructiveHint": False, "openWorldHint": False}
335 _WRITE_HINTS = {"readOnlyHint": False, "destructiveHint": False, "idempotentHint": False}
336 _ELICIT_HINTS = {"readOnlyHint": False, "destructiveHint": False, "openWorldHint": True}
337 _DESTRUCTIVE_NAMES = {"muse_push"}
338
339 import json as _json
340 stripped = [{k: v for k, v in t.items() if k != "server_side"} for t in MCP_TOOLS]
341 for t in stripped:
342 if "annotations" not in t:
343 name = t.get("name", "")
344 if name in MUSEHUB_ELICITATION_TOOL_NAMES:
345 t["annotations"] = _ELICIT_HINTS
346 elif name in MUSEHUB_WRITE_TOOL_NAMES:
347 hints = dict(_WRITE_HINTS)
348 if name in _DESTRUCTIVE_NAMES:
349 hints["destructiveHint"] = True
350 t["annotations"] = hints
351 else:
352 t["annotations"] = _READ_HINTS
353
354 raw = _json.dumps(stripped)
355 tools: list[JSONValue] = _json.loads(raw)
356 return {"tools": tools}
357
358 async def _handle_tools_call(
359 params: JSONObject,
360 *,
361 user_id: str | None,
362 session: "MCPSession | None",
363 is_agent: bool = False,
364 agent_name: str | None = None,
365 ) -> JSONObject:
366 """Route a ``tools/call`` request to the appropriate executor."""
367 name = params.get("name")
368 arguments = params.get("arguments") or {}
369 meta = params.get("_meta") or {}
370
371 if not isinstance(name, str):
372 raise _MCPError(_INVALID_PARAMS, "tools/call requires a 'name' string parameter")
373 if not isinstance(arguments, dict):
374 raise _MCPError(_INVALID_PARAMS, "tools/call 'arguments' must be an object")
375
376 # Auth gate: write tools require an authenticated user.
377 if name in MUSEHUB_WRITE_TOOL_NAMES and user_id is None:
378 return _tool_error(f"Tool '{name}' requires authentication. Provide an MSign Authorization header.")
379
380 # Build tool call context with session for elicitation/progress support.
381 from musehub.mcp.context import ToolCallContext
382 progress_token: str | None = None
383 if isinstance(meta, dict):
384 pt = meta.get("progressToken")
385 if isinstance(pt, str):
386 progress_token = pt
387
388 ctx = ToolCallContext(
389 user_id=user_id,
390 session=session,
391 is_agent=is_agent,
392 agent_name=agent_name,
393 )
394
395 # Inject session repo focus when owner/slug are absent.
396 # Explicit arguments always take precedence over session focus.
397 if (
398 not arguments.get("repo_id")
399 and not arguments.get("owner")
400 and not arguments.get("slug")
401 and session is not None
402 and session.repo_focus is not None
403 ):
404 focused_owner, focused_slug = session.repo_focus
405 arguments = {**arguments, "owner": focused_owner, "slug": focused_slug}
406
407 # Resolve owner+slug → repo_id transparently so all repo-scoped tools
408 # can be called with either addressing scheme.
409 if not arguments.get("repo_id") and arguments.get("owner") and arguments.get("slug"):
410 resolved_id, resolve_err = await _resolve_repo_id(
411 str(arguments["owner"]), str(arguments["slug"])
412 )
413 if resolve_err:
414 return _tool_error(resolve_err)
415 arguments = {**arguments, "repo_id": resolved_id}
416
417 try:
418 return await _call_tool(name, arguments, ctx=ctx)
419 except Exception as exc:
420 logger.exception("Tool execution error (tool=%s): %s", name, exc)
421 # Strip exc details from the response — full trace is logged server-side only.
422 return _tool_error(f"Internal error executing tool '{name}'")
423
424 async def _resolve_repo_id(owner: str, slug: str) -> tuple[str, str | None]:
425 """Resolve a repo_id from owner/slug addressing.
426
427 Returns ``(repo_id, None)`` on success or ``("", error_message)`` on failure.
428 Used so that all repo-scoped tools can accept either ``repo_id`` or
429 ``owner`` + ``slug`` interchangeably.
430 """
431 try:
432 from musehub.services.musehub_mcp_executor import _check_db_available
433 if (err := _check_db_available()) is not None:
434 return "", err.error_message or "Database unavailable"
435 from musehub.db.database import AsyncSessionLocal
436 from musehub.services import musehub_repository as _repo_svc
437 async with AsyncSessionLocal() as db:
438 repo = await _repo_svc.get_repo_by_owner_slug(db, owner, slug)
439 if repo is None:
440 return "", f"Repository '{owner}/{slug}' not found."
441 return repo.repo_id, None
442 except Exception as exc:
443 return "", f"Failed to resolve repository '{owner}/{slug}': {exc}"
444
445 async def _call_tool(
446 name: str,
447 arguments: JSONObject,
448 *,
449 ctx: "ToolCallContext",
450 ) -> JSONObject:
451 """Delegate to the correct executor and wrap result in MCP content block."""
452 from musehub.services import musehub_mcp_executor as exe
453
454 user_id = ctx.user_id
455
456 def _str(key: str) -> str:
457 v = arguments.get(key, "")
458 return str(v) if v is not None else ""
459
460 def _int(key: str, default: int = 20) -> int:
461 v = arguments.get(key)
462 if isinstance(v, int):
463 return v
464 if isinstance(v, float):
465 return int(v)
466 return default
467
468 def _bool(key: str, default: bool = False) -> bool:
469 v = arguments.get(key)
470 if isinstance(v, bool):
471 return v
472 return default
473
474 def _float_or_none(key: str) -> float | None:
475 v = arguments.get(key)
476 if isinstance(v, (int, float)):
477 return float(v)
478 return None
479
480 def _str_or_none(key: str) -> str | None:
481 v = arguments.get(key)
482 return str(v) if v is not None else None
483
484 def _list_str(key: str) -> list[str]:
485 v = arguments.get(key)
486 if isinstance(v, list):
487 return [str(x) for x in v]
488 return []
489
490 # ── Read tools ────────────────────────────────────────────────────────────
491
492 if name == "musehub_set_context":
493 owner_arg = _str("owner")
494 slug_arg = _str("slug")
495 if not owner_arg or not slug_arg:
496 from musehub.services.musehub_mcp_executor import MusehubToolResult
497 result = MusehubToolResult(
498 ok=False,
499 error_code="missing_args",
500 error_message="musehub_set_context requires both 'owner' and 'slug'.",
501 hint="Call musehub_set_context(owner, slug) to set a session-wide repo focus so you don't need to pass owner/slug on every call.",
502 )
503 else:
504 repo_id_val, resolve_err = await _resolve_repo_id(owner_arg, slug_arg)
505 if resolve_err:
506 from musehub.services.musehub_mcp_executor import MusehubToolResult
507 result = MusehubToolResult(
508 ok=False,
509 error_code="repo_not_found",
510 error_message=resolve_err,
511 hint="Call musehub_search_repos() to find available repositories, or musehub_set_context(owner, slug) to focus on a repo.",
512 )
513 else:
514 if ctx.session is not None:
515 ctx.session.repo_focus = (owner_arg, slug_arg)
516 from musehub.services.musehub_mcp_executor import MusehubToolResult
517 result = MusehubToolResult(
518 ok=True,
519 data={
520 "focused": True,
521 "owner": owner_arg,
522 "slug": slug_arg,
523 "repo_id": repo_id_val,
524 "message": (
525 f"Session is now focused on {owner_arg}/{slug_arg}. "
526 "Subsequent tool calls that accept owner/slug will use "
527 "this repo automatically. Pass explicit owner+slug to override."
528 ),
529 },
530 )
531 elif name == "musehub_list_branches":
532 result = await exe.execute_list_branches(_str("repo_id"))
533 elif name == "musehub_list_commits":
534 result = await exe.execute_list_commits(
535 _str("repo_id"),
536 branch=_str_or_none("branch"),
537 limit=_int("limit", 20),
538 )
539 elif name == "musehub_read_file":
540 result = await exe.execute_read_file(_str("repo_id"), _str("object_id"))
541 elif name == "musehub_search":
542 result = await exe.execute_search(
543 _str("repo_id"),
544 query=_str("query"),
545 mode=_str_or_none("mode") or "path",
546 )
547 elif name == "musehub_read_context":
548 result = await exe.execute_read_context(_str("repo_id"))
549 elif name == "musehub_read_commit":
550 result = await exe.execute_read_commit(_str("repo_id"), _str("commit_id"))
551 elif name == "musehub_compare":
552 result = await exe.execute_compare(
553 _str("repo_id"),
554 base_ref=_str("base_ref"),
555 head_ref=_str("head_ref"),
556 )
557 elif name == "musehub_list_issues":
558 result = await exe.execute_list_issues(
559 _str("repo_id"),
560 state=_str_or_none("state") or "open",
561 label=_str_or_none("label"),
562 )
563 elif name == "musehub_read_issue":
564 result = await exe.execute_read_issue(_str("repo_id"), _int("issue_number", 0))
565 elif name == "musehub_list_proposals":
566 result = await exe.execute_list_proposals(
567 _str("repo_id"),
568 state=_str_or_none("state") or "all",
569 )
570 elif name == "musehub_list_proposals_context":
571 result = await exe.execute_list_proposals_context(
572 _str("repo_id"),
573 state=_str_or_none("state") or "open",
574 )
575 elif name == "musehub_read_proposal":
576 result = await exe.execute_read_proposal(_str("repo_id"), _str("proposal_id"))
577 elif name == "musehub_read_proposal_risk":
578 result = await exe.execute_read_proposal_risk(_str("repo_id"), _str("proposal_id"))
579 elif name == "musehub_read_proposal_diff":
580 result = await exe.execute_read_proposal_diff(_str("repo_id"), _str("proposal_id"))
581 elif name == "musehub_read_proposal_breakage":
582 result = await exe.execute_read_proposal_breakage(_str("repo_id"), _str("proposal_id"))
583 elif name == "musehub_list_releases":
584 result = await exe.execute_list_releases(_str("repo_id"))
585 elif name == "musehub_get_repo":
586 result = await exe.execute_get_repo(
587 repo_id=_str_or_none("repo_id"),
588 owner=_str_or_none("owner"),
589 slug=_str_or_none("slug"),
590 actor=user_id or "",
591 )
592 elif name == "musehub_list_repos":
593 result = await exe.execute_list_repos(
594 actor=user_id or "",
595 limit=_int("limit", 20),
596 cursor=_str_or_none("cursor"),
597 )
598 elif name == "musehub_search_repos":
599 result = await exe.execute_search_repos(
600 query=_str_or_none("query"),
601 domain=_str_or_none("domain"),
602 tags=_list_str("tags"),
603 limit=_int("limit", 20),
604 )
605 elif name == "musehub_list_repo_forks":
606 result = await exe.execute_list_repo_forks(repo_id=_str("repo_id"))
607 elif name == "musehub_get_fork_network":
608 result = await exe.execute_get_fork_network(repo_id=_str("repo_id"))
609 elif name == "musehub_get_user_forks":
610 result = await exe.execute_get_user_forks(username=_str("username"))
611 elif name == "musehub_list_domains":
612 result = await exe.execute_list_domains(
613 query=_str_or_none("query"),
614 viewer_type=_str_or_none("viewer_type"),
615 verified=bool(arguments["verified"]) if "verified" in arguments else None,
616 limit=_int("limit", 20),
617 cursor=_str_or_none("cursor"),
618 )
619 elif name == "musehub_read_domain":
620 result = await exe.execute_read_domain(_str("scoped_id"))
621 elif name == "musehub_read_domain_insights":
622 result = await exe.execute_read_domain_insights(
623 repo_id=_str("repo_id"),
624 dimension=_str_or_none("dimension") or "overview",
625 ref=_str_or_none("ref"),
626 )
627 elif name == "musehub_read_view":
628 result = await exe.execute_read_view(
629 repo_id=_str("repo_id"),
630 ref=_str_or_none("ref"),
631 dimension=_str_or_none("dimension"),
632 )
633
634 elif name == "musehub_read_prompt":
635 raw_prompt_args = arguments.get("arguments")
636 prompt_args: StrDict | None = (
637 {str(k): str(v) for k, v in raw_prompt_args.items()}
638 if isinstance(raw_prompt_args, dict)
639 else None
640 )
641 result = exe.execute_read_prompt(
642 name=_str("name"),
643 arguments=prompt_args,
644 )
645
646 # ── Standard write tools ──────────────────────────────────────────────────
647
648 elif name == "musehub_create_repo":
649 from musehub.mcp.write_tools.repos import execute_create_repo
650 result = await execute_create_repo(
651 name=_str("name"),
652 owner=user_id or "",
653 owner_user_id=user_id or "",
654 description=_str_or_none("description") or "",
655 visibility=_str_or_none("visibility") or "public",
656 tags=_list_str("tags") or None,
657 initialize=_bool("initialize", True),
658 )
659 elif name == "musehub_fork_repo":
660 from musehub.mcp.write_tools.repos import execute_fork_repo
661 result = await execute_fork_repo(
662 source_repo_id=_str("source_repo_id"),
663 actor=user_id or "",
664 name=_str_or_none("name"),
665 visibility=_str_or_none("visibility") or "public",
666 description=_str_or_none("description"),
667 )
668 elif name == "musehub_delete_repo":
669 from musehub.mcp.write_tools.repos import execute_delete_repo
670 result = await execute_delete_repo(
671 repo_id=_str("repo_id"),
672 actor=user_id or "",
673 )
674 elif name == "musehub_update_repo":
675 from musehub.mcp.write_tools.repos import execute_update_repo
676 _topics = arguments.get("topics")
677 result = await execute_update_repo(
678 repo_id=_str("repo_id"),
679 actor=user_id or "",
680 name=_str_or_none("name"),
681 description=_str_or_none("description"),
682 visibility=_str_or_none("visibility"),
683 default_branch=_str_or_none("default_branch"),
684 has_issues=bool(arguments["has_issues"]) if "has_issues" in arguments else None,
685 has_wiki=bool(arguments["has_wiki"]) if "has_wiki" in arguments else None,
686 topics=[str(t) for t in _topics] if isinstance(_topics, list) else None,
687 homepage_url=_str_or_none("homepage_url"),
688 allow_merge_commit=bool(arguments["allow_merge_commit"]) if "allow_merge_commit" in arguments else None,
689 allow_squash_merge=bool(arguments["allow_squash_merge"]) if "allow_squash_merge" in arguments else None,
690 allow_rebase_merge=bool(arguments["allow_rebase_merge"]) if "allow_rebase_merge" in arguments else None,
691 delete_branch_on_merge=bool(arguments["delete_branch_on_merge"]) if "delete_branch_on_merge" in arguments else None,
692 )
693 elif name == "musehub_transfer_repo_ownership":
694 from musehub.mcp.write_tools.repos import execute_transfer_repo_ownership
695 result = await execute_transfer_repo_ownership(
696 repo_id=_str("repo_id"),
697 new_owner=_str("new_owner"),
698 actor=user_id or "",
699 )
700 elif name == "musehub_create_issue":
701 from musehub.mcp.write_tools.issues import execute_create_issue
702 result = await execute_create_issue(
703 repo_id=_str("repo_id"),
704 title=_str("title"),
705 body=_str_or_none("body") or "",
706 labels=_list_str("labels") or None,
707 actor=user_id or "",
708 )
709 elif name == "musehub_update_issue":
710 from musehub.mcp.write_tools.issues import execute_update_issue
711 result = await execute_update_issue(
712 repo_id=_str("repo_id"),
713 issue_number=_int("issue_number", 0),
714 title=_str_or_none("title"),
715 body=_str_or_none("body"),
716 labels=_list_str("labels") if "labels" in arguments else None,
717 state=_str_or_none("state"),
718 assignee=_str_or_none("assignee"),
719 actor=user_id or "",
720 )
721 elif name == "musehub_create_issue_comment":
722 from musehub.mcp.write_tools.issues import execute_create_issue_comment
723 result = await execute_create_issue_comment(
724 repo_id=_str("repo_id"),
725 issue_number=_int("issue_number", 0),
726 body=_str("body"),
727 actor=user_id or "",
728 )
729 # ── Symbol Intelligence (V2) ──────────────────────────────────────────────
730 elif name == "musehub_list_symbols":
731 from musehub.services.musehub_symbol_indexer import load_symbol_history
732 from musehub.db.database import AsyncSessionLocal
733 from musehub.services.musehub_mcp_executor import MusehubToolResult
734 q = _str_or_none("q")
735 kind = _str_or_none("kind")
736 limit = _int("limit", 100)
737 cursor = _str_or_none("cursor")
738 async with AsyncSessionLocal() as session:
739 history = await load_symbol_history(session, _str("repo_id"))
740 items: list[_SymbolIndexItem] = []
741 for address, entries in history.items():
742 if not entries:
743 continue
744 if q and q.lower() not in address.lower():
745 continue
746 latest = entries[-1]
747 if kind and latest.get("op") != kind:
748 continue
749 items.append({
750 "address": address,
751 "op": latest.get("op"),
752 "content_id": latest.get("content_id"),
753 "last_commit_id": latest.get("commit_id"),
754 "last_modified": latest.get("committed_at"),
755 "history_count": len(entries),
756 })
757 # Sort DESC by (last_modified, address) for stable cursor pagination.
758 items.sort(
759 key=lambda r: (r.get("last_modified") or "", r.get("address") or ""),
760 reverse=True,
761 )
762 # Apply cursor: skip items up to and including the cursor position.
763 # Cursor encodes "last_modified|address" of the last item from the previous page.
764 if cursor:
765 parts = cursor.split("|", 1)
766 cursor_ts = parts[0]
767 cursor_addr = parts[1] if len(parts) > 1 else ""
768 items = [
769 r for r in items
770 if (r.get("last_modified") or "") < cursor_ts
771 or (
772 (r.get("last_modified") or "") == cursor_ts
773 and (r.get("address") or "") < cursor_addr
774 )
775 ]
776 window = items[: limit + 1]
777 has_more = len(window) > limit
778 page = window[:limit]
779 next_cursor_val: str | None = None
780 if has_more and page:
781 last = page[-1]
782 next_cursor_val = f"{last.get('last_modified') or ''}|{last.get('address') or ''}"
783 result = MusehubToolResult(
784 ok=True,
785 data={"symbols": page, "next_cursor": next_cursor_val, "total": len(page)},
786 )
787 elif name == "musehub_read_symbol":
788 from musehub.services.musehub_symbol_indexer import load_symbol_history
789 from musehub.db.database import AsyncSessionLocal
790 from musehub.services.musehub_mcp_executor import MusehubToolResult
791 address = _str("address")
792 async with AsyncSessionLocal() as session:
793 history = await load_symbol_history(session, _str("repo_id"))
794 entries = history.get(address)
795 if entries is None:
796 result = MusehubToolResult(ok=False, error_code="symbol_not_found", error_message=f"Symbol '{address}' not found in index.",
797 hint="Call musehub_list_symbols() to browse the symbol index, or musehub_read_intel_index_status() to check if the index is built.")
798 else:
799 latest = entries[-1] if entries else {}
800 result = MusehubToolResult(ok=True, data={"address": address, "op": latest.get("op"), "content_id": latest.get("content_id"), "last_modified": latest.get("committed_at"), "history": entries})
801 elif name == "musehub_symbol_impact":
802 from musehub.services.musehub_symbol_indexer import load_symbol_history
803 from musehub.db.database import AsyncSessionLocal
804 from musehub.services.musehub_mcp_executor import MusehubToolResult
805 address = _str("address")
806 async with AsyncSessionLocal() as session:
807 history = await load_symbol_history(session, _str("repo_id"))
808 entries = history.get(address)
809 if entries is None:
810 result = MusehubToolResult(ok=False, error_code="symbol_not_found", error_message=f"Symbol '{address}' not found.",
811 hint="Call musehub_list_symbols() to browse the symbol index, or musehub_read_intel_index_status() to check if the index is built.")
812 else:
813 target_commits: set[str] = {e["commit_id"] for e in entries}
814 co_changed: IntDict = {}
815 for other, other_entries in history.items():
816 if other == address:
817 continue
818 shared = sum(1 for e in other_entries if e["commit_id"] in target_commits)
819 if shared > 0:
820 co_changed[other] = shared
821 ranked = sorted(co_changed.items(), key=lambda x: x[1], reverse=True)[:50]
822 result = MusehubToolResult(ok=True, data={"address": address, "commit_count": len(target_commits), "co_changed": [{"address": a, "shared_commits": c} for a, c in ranked]})
823 elif name == "musehub_symbol_clones":
824 from musehub.services.musehub_symbol_indexer import load_symbol_history, load_hash_occurrence
825 from musehub.db.database import AsyncSessionLocal
826 from musehub.services.musehub_mcp_executor import MusehubToolResult
827 address = _str("address")
828 async with AsyncSessionLocal() as session:
829 history = await load_symbol_history(session, _str("repo_id"))
830 hash_occ = await load_hash_occurrence(session, _str("repo_id"))
831 entries = history.get(address)
832 if entries is None:
833 result = MusehubToolResult(ok=False, error_code="symbol_not_found", error_message=f"Symbol '{address}' not found.",
834 hint="Call musehub_list_symbols() to browse the symbol index, or musehub_read_intel_index_status() to check if the index is built.")
835 else:
836 latest = entries[-1] if entries else {}
837 content_id = latest.get("content_id", "")
838 clones = [a for a in hash_occ.get(content_id, []) if a != address]
839 result = MusehubToolResult(ok=True, data={"address": address, "content_id": content_id, "clones": clones, "clone_count": len(clones)})
840 elif name == "musehub_read_intel_index_status":
841 from musehub.services.musehub_symbol_indexer import get_index_meta
842 from musehub.db.database import AsyncSessionLocal
843 from musehub.services.musehub_mcp_executor import MusehubToolResult
844 async with AsyncSessionLocal() as session:
845 meta = await get_index_meta(session, _str("repo_id"))
846 if meta is None:
847 result = MusehubToolResult(ok=True, data={"status": "not_built", "ref": None, "built_at": None, "symbol_count": 0})
848 else:
849 result = MusehubToolResult(ok=True, data={"status": "ready", **meta})
850 elif name == "musehub_read_intel_health_score":
851 result = await exe.execute_read_intel_health_score(_str("repo_id"))
852 elif name == "musehub_read_intel_hotspots":
853 result = await exe.execute_read_intel_hotspots(_str("repo_id"))
854 elif name == "musehub_read_intel_dead":
855 result = await exe.execute_read_intel_dead(_str("repo_id"))
856 elif name == "musehub_read_intel_blast_risk":
857 result = await exe.execute_read_intel_blast_risk(_str("repo_id"))
858 # ── End Symbol Intelligence ───────────────────────────────────────────────
859 elif name == "musehub_create_proposal":
860 from musehub.mcp.write_tools.proposals import execute_create_proposal
861 _depends_raw = arguments.get("depends_on")
862 _selective_raw = arguments.get("selective_domains")
863 _merge_conds = arguments.get("merge_conditions")
864 result = await execute_create_proposal(
865 repo_id=_str("repo_id"),
866 title=_str("title"),
867 from_branch=_str("from_branch"),
868 to_branch=_str("to_branch"),
869 body=_str_or_none("body") or "",
870 proposal_type=_str_or_none("proposal_type") or "state_merge",
871 is_draft=bool(arguments.get("is_draft", False)),
872 merge_strategy=_str_or_none("merge_strategy") or "overlay",
873 merge_conditions=_merge_conds if isinstance(_merge_conds, dict) else None,
874 selective_domains=list(_selective_raw) if isinstance(_selective_raw, list) else None,
875 depends_on=list(_depends_raw) if isinstance(_depends_raw, list) else None,
876 actor=user_id or "",
877 )
878 elif name == "musehub_merge_proposal":
879 from musehub.mcp.write_tools.proposals import execute_merge_proposal
880 result = await execute_merge_proposal(
881 repo_id=_str("repo_id"),
882 proposal_id=_str("proposal_id"),
883 merge_strategy=_str_or_none("merge_strategy") or "merge_commit",
884 actor=user_id or "",
885 )
886 elif name == "musehub_create_proposal_comment":
887 from musehub.mcp.write_tools.proposals import execute_create_proposal_comment
888 _dim_ref = arguments.get("dimension_ref") or {}
889 if not isinstance(_dim_ref, dict):
890 _dim_ref = {}
891 _sym_addr = arguments.get("symbol_address")
892 result = await execute_create_proposal_comment(
893 repo_id=_str("repo_id"),
894 proposal_id=_str("proposal_id"),
895 body=_str("body"),
896 actor=user_id or "",
897 symbol_address=str(_sym_addr) if _sym_addr else None,
898 parent_comment_id=str(arguments["parent_comment_id"]) if arguments.get("parent_comment_id") else None,
899 target_type=str(_dim_ref.get("type", "general")),
900 target_track=str(_dim_ref["track"]) if "track" in _dim_ref else None,
901 target_beat_start=float(_dim_ref["beat_start"]) if isinstance(_dim_ref.get("beat_start"), (int, float)) else None,
902 target_beat_end=float(_dim_ref["beat_end"]) if isinstance(_dim_ref.get("beat_end"), (int, float)) else None,
903 )
904 elif name == "musehub_create_proposal_review":
905 from musehub.mcp.write_tools.proposals import execute_create_proposal_review
906 result = await execute_create_proposal_review(
907 repo_id=_str("repo_id"),
908 proposal_id=_str("proposal_id"),
909 verdict=_str("verdict"),
910 body=_str_or_none("body") or "",
911 reviewer=user_id or "",
912 )
913 elif name == "musehub_list_proposal_comments":
914 from musehub.mcp.write_tools.proposals import execute_list_proposal_comments
915 result = await execute_list_proposal_comments(
916 repo_id=_str("repo_id"),
917 proposal_id=_str("proposal_id"),
918 actor=user_id or "",
919 )
920 elif name == "musehub_request_proposal_reviewers":
921 from musehub.mcp.write_tools.proposals import execute_request_proposal_reviewers
922 result = await execute_request_proposal_reviewers(
923 repo_id=_str("repo_id"),
924 proposal_id=_str("proposal_id"),
925 reviewers=_list_str("reviewers"),
926 actor=user_id or "",
927 )
928 elif name == "musehub_remove_proposal_reviewer":
929 from musehub.mcp.write_tools.proposals import execute_remove_proposal_reviewer
930 result = await execute_remove_proposal_reviewer(
931 repo_id=_str("repo_id"),
932 proposal_id=_str("proposal_id"),
933 reviewer=_str("reviewer"),
934 actor=user_id or "",
935 )
936 elif name == "musehub_list_proposal_reviews":
937 from musehub.mcp.write_tools.proposals import execute_list_proposal_reviews
938 result = await execute_list_proposal_reviews(
939 repo_id=_str("repo_id"),
940 proposal_id=_str("proposal_id"),
941 state=_str_or_none("state"),
942 actor=user_id or "",
943 )
944 elif name == "musehub_get_proposal":
945 from musehub.mcp.write_tools.proposals import execute_get_proposal
946 result = await execute_get_proposal(
947 repo_id=_str("repo_id"),
948 proposal_id=_str("proposal_id"),
949 actor=user_id or "",
950 )
951 elif name == "musehub_run_proposal_simulation":
952 from musehub.mcp.write_tools.proposals import execute_run_simulation
953 result = await execute_run_simulation(
954 repo_id=_str("repo_id"),
955 proposal_id=_str("proposal_id"),
956 simulation_type=_str("simulation_type"),
957 actor=user_id or "",
958 )
959 elif name == "musehub_get_proposal_simulation":
960 from musehub.mcp.write_tools.proposals import execute_get_simulation
961 result = await execute_get_simulation(
962 repo_id=_str("repo_id"),
963 proposal_id=_str("proposal_id"),
964 simulation_type=_str("simulation_type"),
965 actor=user_id or "",
966 )
967 elif name == "musehub_list_proposal_simulations":
968 from musehub.mcp.write_tools.proposals import execute_list_simulations
969 result = await execute_list_simulations(
970 repo_id=_str("repo_id"),
971 proposal_id=_str("proposal_id"),
972 actor=user_id or "",
973 )
974 elif name == "musehub_create_release":
975 from musehub.mcp.write_tools.releases import execute_create_release
976 result = await execute_create_release(
977 repo_id=_str("repo_id"),
978 tag=_str("tag"),
979 title=_str("title"),
980 body=_str_or_none("body") or "",
981 commit_id=_str_or_none("commit_id"),
982 channel=_str_or_none("channel") or "stable",
983 actor=user_id or "",
984 )
985 elif name == "musehub_create_label":
986 from musehub.mcp.write_tools.labels import execute_create_label
987 result = await execute_create_label(
988 repo_id=_str("repo_id"),
989 name=_str("name"),
990 color=_str("color"),
991 description=_str_or_none("description") or "",
992 actor=user_id or "",
993 )
994 elif name == "musehub_list_labels":
995 result = await exe.execute_list_labels(_str("repo_id"))
996 elif name == "musehub_update_label":
997 from musehub.mcp.write_tools.labels import execute_update_label
998 result = await execute_update_label(
999 repo_id=_str("repo_id"),
1000 label_id=_str("label_id"),
1001 name=_str_or_none("name"),
1002 color=_str_or_none("color"),
1003 description=_str_or_none("description"),
1004 actor=user_id or "",
1005 )
1006 elif name == "musehub_delete_label":
1007 from musehub.mcp.write_tools.labels import execute_delete_label
1008 result = await execute_delete_label(
1009 repo_id=_str("repo_id"),
1010 label_id=_str("label_id"),
1011 actor=user_id or "",
1012 )
1013 elif name == "musehub_close_issue":
1014 from musehub.mcp.write_tools.issues import execute_close_issue
1015 result = await execute_close_issue(
1016 repo_id=_str("repo_id"),
1017 issue_number=_int("issue_number", 0),
1018 actor=user_id or "",
1019 )
1020 elif name == "musehub_reopen_issue":
1021 from musehub.mcp.write_tools.issues import execute_reopen_issue
1022 result = await execute_reopen_issue(
1023 repo_id=_str("repo_id"),
1024 issue_number=_int("issue_number", 0),
1025 actor=user_id or "",
1026 )
1027 elif name == "musehub_assign_issue":
1028 from musehub.mcp.write_tools.issues import execute_assign_issue
1029 result = await execute_assign_issue(
1030 repo_id=_str("repo_id"),
1031 issue_number=_int("issue_number", 0),
1032 assignee=_str("assignee"),
1033 actor=user_id or "",
1034 )
1035 elif name == "musehub_update_issue_labels":
1036 from musehub.mcp.write_tools.issues import execute_update_issue_labels
1037 result = await execute_update_issue_labels(
1038 repo_id=_str("repo_id"),
1039 issue_number=_int("issue_number", 0),
1040 labels=_list_str("labels"),
1041 actor=user_id or "",
1042 )
1043 elif name == "musehub_remove_issue_label":
1044 from musehub.mcp.write_tools.issues import execute_remove_issue_label
1045 result = await execute_remove_issue_label(
1046 repo_id=_str("repo_id"),
1047 issue_number=_int("issue_number", 0),
1048 label=_str("label"),
1049 actor=user_id or "",
1050 )
1051 elif name == "musehub_delete_issue_comment":
1052 from musehub.mcp.write_tools.issues import execute_delete_issue_comment
1053 result = await execute_delete_issue_comment(
1054 repo_id=_str("repo_id"),
1055 issue_number=_int("issue_number", 0),
1056 comment_id=_str("comment_id"),
1057 actor=user_id or "",
1058 )
1059
1060 # ── Collaborator management ───────────────────────────────────────────────
1061
1062 elif name == "musehub_list_collaborators":
1063 from musehub.mcp.write_tools.collaborators import execute_list_collaborators
1064 result = await execute_list_collaborators(
1065 repo_id=_str("repo_id"),
1066 actor=user_id or "",
1067 )
1068 elif name == "musehub_invite_collaborator":
1069 from musehub.mcp.write_tools.collaborators import execute_invite_collaborator
1070 result = await execute_invite_collaborator(
1071 repo_id=_str("repo_id"),
1072 handle=_str("handle"),
1073 permission=_str("permission") or "write",
1074 actor=user_id or "",
1075 )
1076 elif name == "musehub_update_collaborator_permission":
1077 from musehub.mcp.write_tools.collaborators import execute_update_collaborator_permission
1078 result = await execute_update_collaborator_permission(
1079 repo_id=_str("repo_id"),
1080 handle=_str("handle"),
1081 permission=_str("permission"),
1082 actor=user_id or "",
1083 )
1084 elif name == "musehub_remove_collaborator":
1085 from musehub.mcp.write_tools.collaborators import execute_remove_collaborator
1086 result = await execute_remove_collaborator(
1087 repo_id=_str("repo_id"),
1088 handle=_str("handle"),
1089 actor=user_id or "",
1090 )
1091
1092 # ── Webhook management ────────────────────────────────────────────────────
1093
1094 elif name == "musehub_create_webhook":
1095 from musehub.mcp.write_tools.webhooks import execute_create_webhook
1096 result = await execute_create_webhook(
1097 repo_id=_str("repo_id"),
1098 url=_str("url"),
1099 events=_list_str("events"),
1100 secret=_str("secret") or "",
1101 actor=user_id or "",
1102 )
1103 elif name == "musehub_list_webhooks":
1104 from musehub.mcp.write_tools.webhooks import execute_list_webhooks
1105 result = await execute_list_webhooks(
1106 repo_id=_str("repo_id"),
1107 actor=user_id or "",
1108 )
1109 elif name == "musehub_delete_webhook":
1110 from musehub.mcp.write_tools.webhooks import execute_delete_webhook
1111 result = await execute_delete_webhook(
1112 repo_id=_str("repo_id"),
1113 webhook_id=_str("webhook_id"),
1114 actor=user_id or "",
1115 )
1116
1117 # ── Release asset management ──────────────────────────────────────────────
1118
1119 elif name == "musehub_attach_release_asset":
1120 from musehub.mcp.write_tools.releases import execute_attach_release_asset
1121 result = await execute_attach_release_asset(
1122 repo_id=_str("repo_id"),
1123 tag=_str("tag"),
1124 name=_str("name"),
1125 download_url=_str("download_url"),
1126 label=_str("label") or "",
1127 content_type=_str("content_type") or "",
1128 size=_int("size", 0),
1129 actor=user_id or "",
1130 )
1131 elif name == "musehub_delete_release_asset":
1132 from musehub.mcp.write_tools.releases import execute_delete_release_asset
1133 result = await execute_delete_release_asset(
1134 repo_id=_str("repo_id"),
1135 tag=_str("tag"),
1136 asset_id=_str("asset_id"),
1137 actor=user_id or "",
1138 )
1139 elif name == "musehub_list_issue_comments":
1140 result = await exe.execute_list_issue_comments(
1141 repo_id=_str("repo_id"),
1142 issue_number=_int("issue_number"),
1143 limit=_int("limit", 100),
1144 cursor=_str_or_none("cursor"),
1145 )
1146 elif name == "musehub_update_release":
1147 result = await exe.execute_update_release(
1148 repo_id=_str("repo_id"),
1149 tag=_str("tag"),
1150 title=_str_or_none("title"),
1151 body=_str_or_none("body"),
1152 channel=_str_or_none("channel"),
1153 is_draft=arguments.get("is_draft"),
1154 actor=user_id or "",
1155 )
1156 elif name == "musehub_list_release_assets":
1157 result = await exe.execute_list_release_assets(
1158 repo_id=_str("repo_id"),
1159 tag=_str("tag"),
1160 limit=_int("limit", 50),
1161 cursor=_str_or_none("cursor"),
1162 )
1163 elif name == "musehub_read_user_profile":
1164 result = await exe.execute_read_user_profile(username=_str("username"))
1165 elif name == "musehub_update_user_profile":
1166 _pinned = arguments.get("pinned_repo_ids")
1167 result = await exe.execute_update_user_profile(
1168 username=_str("username"),
1169 bio=_str_or_none("bio"),
1170 avatar_url=_str_or_none("avatar_url"),
1171 pinned_repo_ids=list(_pinned) if isinstance(_pinned, list) else None,
1172 actor=user_id or "",
1173 )
1174 elif name == "musehub_read_profile_manifest":
1175 result = await exe.execute_read_profile_manifest(handle=_str("handle"))
1176 elif name == "musehub_issue_attestation":
1177 result = await exe.execute_issue_attestation(
1178 attester=_str("attester"),
1179 subject=_str("subject"),
1180 claim=_str("claim"),
1181 issued_at_iso=_str("issued_at_iso"),
1182 signature=_str("signature"),
1183 attester_public_key=_str("attester_public_key"),
1184 )
1185 elif name == "musehub_revoke_attestation":
1186 result = await exe.execute_revoke_attestation(
1187 attestation_id=_str("attestation_id"),
1188 revoker=_str("revoker"),
1189 )
1190 elif name == "musehub_list_attestations":
1191 result = await exe.execute_list_attestations(
1192 subject=_str("subject"),
1193 include_revoked=bool(arguments.get("include_revoked", False)),
1194 )
1195 elif name == "musehub_record_mpay_claim":
1196 result = await exe.execute_record_mpay_claim(
1197 sender=_str("sender"),
1198 recipient=_str("recipient"),
1199 amount_nano=_int("amount_nano", 0),
1200 nonce_hex=_str("nonce_hex"),
1201 signature=_str("signature"),
1202 sender_public_key=_str("sender_public_key"),
1203 memo=_str_or_none("memo"),
1204 )
1205 elif name == "musehub_get_mpay_ledger":
1206 result = await exe.execute_get_mpay_ledger(
1207 handle=_str("handle"),
1208 limit=_int("limit", 100),
1209 )
1210 elif name == "musehub_list_topics":
1211 result = await exe.execute_list_topics(
1212 query=_str_or_none("query"),
1213 limit=_int("limit", 50),
1214 )
1215 elif name == "musehub_set_repo_topics":
1216 _topics = arguments.get("topics")
1217 result = await exe.execute_set_repo_topics(
1218 repo_id=_str("repo_id"),
1219 topics=list(_topics) if isinstance(_topics, list) else [],
1220 actor=user_id or "",
1221 )
1222 elif name == "musehub_list_webhook_deliveries":
1223 result = await exe.execute_list_webhook_deliveries(
1224 repo_id=_str("repo_id"),
1225 webhook_id=_str("webhook_id"),
1226 limit=_int("limit", 20),
1227 cursor=_str_or_none("cursor"),
1228 )
1229 elif name == "musehub_redeliver_webhook":
1230 result = await exe.execute_redeliver_webhook(
1231 repo_id=_str("repo_id"),
1232 webhook_id=_str("webhook_id"),
1233 delivery_id=_str("delivery_id"),
1234 actor=user_id or "",
1235 )
1236
1237 # ── Elicitation-powered tools (MCP 2025-11-25) ────────────────────────────
1238
1239 elif name == "musehub_review_proposal_interactive":
1240 from musehub.mcp.write_tools.elicitation_tools import execute_review_proposal_interactive
1241 result = await execute_review_proposal_interactive(
1242 repo_id=_str("repo_id"),
1243 proposal_id=_str("proposal_id"),
1244 dimension=_str_or_none("dimension"),
1245 depth=_str_or_none("depth"),
1246 ctx=ctx,
1247 )
1248 elif name == "musehub_create_release_interactive":
1249 from musehub.mcp.write_tools.elicitation_tools import execute_create_release_interactive
1250 result = await execute_create_release_interactive(
1251 repo_id=_str("repo_id"),
1252 tag=_str_or_none("tag"),
1253 title=_str_or_none("title"),
1254 notes=_str_or_none("notes"),
1255 ctx=ctx,
1256 )
1257
1258 # ── Muse CLI + auth tools ─────────────────────────────────────────────────
1259
1260 elif name == "musehub_whoami":
1261 result = await exe.execute_whoami(user_id=user_id)
1262 elif name == "musehub_agent_notify":
1263 _raw_payload = arguments.get("payload")
1264 result = exe.execute_agent_notify(
1265 sender_session_id=ctx.session.session_id if ctx.session else "",
1266 sender_handle=user_id,
1267 target_handle=_str("target_handle"),
1268 event=_str("event"),
1269 payload=dict(_raw_payload) if isinstance(_raw_payload, dict) else {},
1270 )
1271 elif name == "musehub_agent_broadcast":
1272 _raw_payload = arguments.get("payload")
1273 result = exe.execute_agent_broadcast(
1274 sender_session_id=ctx.session.session_id if ctx.session else "",
1275 sender_handle=user_id,
1276 event=_str("event"),
1277 payload=dict(_raw_payload) if isinstance(_raw_payload, dict) else {},
1278 repo_focus=ctx.session.repo_focus if ctx.session else None,
1279 )
1280 elif name == "muse_push":
1281 _raw_commits = arguments.get("commits")
1282 _raw_snapshots = arguments.get("snapshots")
1283 _raw_blobs = arguments.get("blobs")
1284 result = await exe.execute_muse_push(
1285 repo_id=_str("repo_id"),
1286 branch=_str("branch"),
1287 head_commit_id=_str("head_commit_id"),
1288 commits=list(_raw_commits) if isinstance(_raw_commits, list) else [],
1289 snapshots=list(_raw_snapshots) if isinstance(_raw_snapshots, list) else None,
1290 blobs=list(_raw_blobs) if isinstance(_raw_blobs, list) else None,
1291 force=_bool("force", False),
1292 user_id=user_id or "",
1293 )
1294 elif name == "muse_pull":
1295 result = await exe.execute_muse_pull(
1296 repo_id=_str("repo_id"),
1297 branch=_str_or_none("branch"),
1298 since_commit_id=_str_or_none("since_commit_id"),
1299 blob_ids=_list_str("blob_ids"),
1300 )
1301 elif name == "muse_remote":
1302 result = await exe.execute_muse_remote(
1303 owner=_str("owner"),
1304 slug=_str("slug"),
1305 ref=_str_or_none("ref"),
1306 )
1307 elif name == "muse_config":
1308 result = await exe.execute_muse_config(
1309 key=_str_or_none("key"),
1310 value=_str_or_none("value"),
1311 )
1312 elif name == "musehub_publish_domain":
1313 _raw_capabilities = arguments.get("capabilities")
1314 result = await exe.execute_musehub_publish_domain(
1315 author_slug=_str("author_slug"),
1316 slug=_str("slug"),
1317 display_name=_str("display_name"),
1318 description=_str("description"),
1319 capabilities=dict(_raw_capabilities) if isinstance(_raw_capabilities, dict) else {},
1320 viewer_type=_str("viewer_type"),
1321 version=_str("version") or "0.1.0",
1322 user_id=user_id or "",
1323 )
1324
1325 # ── Coordination read tools ───────────────────────────────────────────────
1326 elif name == "musehub_read_coord_swarm":
1327 result = await exe.execute_read_coord_swarm(_str("repo_id"))
1328 elif name == "musehub_list_coord_reservations":
1329 result = await exe.execute_list_coord_reservations(
1330 _str("repo_id"),
1331 agent_id=_str_or_none("agent_id"),
1332 include_expired=_bool("include_expired", False),
1333 limit=_int("limit", 200),
1334 )
1335 elif name == "musehub_read_coord_conflicts":
1336 result = await exe.execute_read_coord_conflicts(
1337 _str("repo_id"),
1338 addresses=_list_str("addresses"),
1339 )
1340 elif name == "musehub_list_coord_tasks":
1341 result = await exe.execute_list_coord_tasks(
1342 _str("repo_id"),
1343 queue=_str_or_none("queue"),
1344 status=_str_or_none("status"),
1345 limit=_int("limit", 100),
1346 )
1347
1348 # ── Coordination write tools ──────────────────────────────────────────────
1349 elif name == "musehub_claim_coord_task":
1350 result = await exe.execute_claim_coord_task(
1351 _str("repo_id"),
1352 _str("task_id"),
1353 _str("agent_id"),
1354 user_id=user_id or "",
1355 )
1356 elif name == "musehub_complete_coord_task":
1357 _raw_result = arguments.get("result")
1358 result = await exe.execute_complete_coord_task(
1359 _str("repo_id"),
1360 _str("task_id"),
1361 _str("agent_id"),
1362 result=dict(_raw_result) if isinstance(_raw_result, dict) else None,
1363 )
1364 elif name == "musehub_fail_coord_task":
1365 result = await exe.execute_fail_coord_task(
1366 _str("repo_id"),
1367 _str("task_id"),
1368 _str("agent_id"),
1369 reason=_str("reason"),
1370 )
1371 elif name == "musehub_extend_coord_reservation":
1372 result = await exe.execute_extend_coord_reservation(
1373 _str("repo_id"),
1374 _str("reservation_id"),
1375 extend_by_s=_int("extend_by_s", 300),
1376 )
1377 elif name == "musehub_create_coord_reservation":
1378 _raw_addresses = arguments.get("addresses")
1379 result = await exe.execute_create_coord_reservation(
1380 _str("repo_id"),
1381 addresses=list(_raw_addresses) if isinstance(_raw_addresses, list) else [],
1382 agent_id=_str("agent_id"),
1383 ttl_s=_int("ttl_s", 300),
1384 )
1385 elif name == "musehub_delete_coord_reservation":
1386 result = await exe.execute_delete_coord_reservation(
1387 _str("repo_id"),
1388 reservation_id=_str("reservation_id"),
1389 agent_id=_str("agent_id"),
1390 )
1391 elif name == "musehub_enqueue_coord_task":
1392 _raw_payload = arguments.get("payload")
1393 _raw_depends = arguments.get("depends_on")
1394 result = await exe.execute_enqueue_coord_task(
1395 _str("repo_id"),
1396 queue=_str("queue"),
1397 payload=dict(_raw_payload) if isinstance(_raw_payload, dict) else {},
1398 agent_id=_str("agent_id"),
1399 priority=_int("priority", 50),
1400 depends_on=list(_raw_depends) if isinstance(_raw_depends, list) else None,
1401 )
1402
1403 elif name == "musehub_read_cross_repo_impact":
1404 result = await exe.execute_read_cross_repo_impact(
1405 _str("repo_id"),
1406 _str("address"),
1407 )
1408
1409 elif name == "musehub_read_workspace_intel":
1410 result = await exe.execute_read_workspace_intel(
1411 _str("owner"),
1412 )
1413
1414 # ── Mist tools ────────────────────────────────────────────────────────────
1415 elif name == "muse_mist_read":
1416 result = await exe.execute_read_mist(
1417 _str("mist_id"),
1418 actor=user_id or "",
1419 )
1420 elif name == "muse_mist_list":
1421 result = await exe.execute_list_mists(
1422 _str_or_none("owner"),
1423 artifact_type=_str_or_none("artifact_type"),
1424 include_secret=bool(arguments.get("include_secret", False)),
1425 cursor=_str_or_none("cursor"),
1426 limit=_int("limit", 20),
1427 actor=user_id or "",
1428 )
1429 elif name == "muse_mist_embed":
1430 result = await exe.execute_read_mist_embed(
1431 _str("mist_id"),
1432 owner=_str("owner"),
1433 actor=user_id or "",
1434 )
1435 elif name == "muse_mist_list_forks":
1436 result = await exe.execute_list_mist_forks(
1437 _str("mist_id"),
1438 limit=_int("limit", 20),
1439 actor=user_id or "",
1440 )
1441 elif name == "muse_mist_raw":
1442 result = await exe.execute_read_mist_raw(
1443 _str("mist_id"),
1444 actor=user_id or "",
1445 )
1446 elif name == "muse_mist_create":
1447 from musehub.mcp.write_tools.mists import execute_create_mist
1448 result = await execute_create_mist(
1449 filename=_str("filename"),
1450 content=_str("content"),
1451 actor=user_id or "",
1452 title=_str_or_none("title") or "",
1453 description=_str_or_none("description") or "",
1454 visibility=_str_or_none("visibility") or "public",
1455 tags=_list_str("tags") if "tags" in arguments else None,
1456 agent_id=_str_or_none("agent_id") or "",
1457 model_id=_str_or_none("model_id") or "",
1458 gpg_signature=_str_or_none("gpg_signature"),
1459 )
1460 elif name == "muse_mist_update":
1461 from musehub.mcp.write_tools.mists import execute_update_mist
1462 result = await execute_update_mist(
1463 mist_id=_str("mist_id"),
1464 actor=user_id or "",
1465 title=_str_or_none("title"),
1466 description=_str_or_none("description"),
1467 visibility=_str_or_none("visibility"),
1468 tags=_list_str("tags") if "tags" in arguments else None,
1469 content=_str_or_none("content"),
1470 )
1471 elif name == "muse_mist_fork":
1472 from musehub.mcp.write_tools.mists import execute_fork_mist
1473 result = await execute_fork_mist(
1474 mist_id=_str("mist_id"),
1475 actor=user_id or "",
1476 )
1477 elif name == "muse_mist_delete":
1478 from musehub.mcp.write_tools.mists import execute_delete_mist
1479 result = await execute_delete_mist(
1480 mist_id=_str("mist_id"),
1481 actor=user_id or "",
1482 )
1483
1484 else:
1485 return _tool_error(f"Unknown tool: {name!r}")
1486
1487 # Wrap MusehubToolResult in an MCP content block.
1488 #
1489 # Prompt-injection guard: tool results may contain user-controlled content
1490 # (commit messages, issue bodies, file paths, repository names). We wrap
1491 # the JSON payload in <musehub_tool_result> tags so the model can
1492 # unambiguously distinguish tool data from system instructions. The system
1493 # prompt in prompts.py instructs the model to treat the content inside
1494 # these tags as DATA ONLY and never as instructions.
1495 if result.ok:
1496 text = (
1497 f"<musehub_tool_result>\n"
1498 f"{json.dumps(result.data, default=str)}\n"
1499 f"</musehub_tool_result>"
1500 )
1501 return {
1502 "content": [{"type": "text", "text": text}],
1503 "isError": False,
1504 }
1505 else:
1506 error_payload: JSONObject = {
1507 "error_code": result.error_code,
1508 "error_message": result.error_message,
1509 }
1510 if result.hint:
1511 error_payload["hint"] = result.hint
1512 error_text = json.dumps(error_payload)
1513 return {
1514 "content": [{"type": "text", "text": error_text}],
1515 "isError": True,
1516 }
1517
1518 def _handle_notifications_cancelled(
1519 params: JSONObject,
1520 *,
1521 session: "MCPSession | None",
1522 ) -> None:
1523 """Cancel a pending elicitation Future on client cancellation.
1524
1525 Called when the client sends ``notifications/cancelled`` with the request
1526 ID of an outstanding ``elicitation/create`` request.
1527 """
1528 if session is None:
1529 return
1530 request_id = params.get("requestId")
1531 if request_id is None:
1532 return
1533 from musehub.mcp.session import cancel_elicitation
1534 if not isinstance(request_id, (str, int)):
1535 return
1536 cancelled = cancel_elicitation(session, request_id)
1537 if cancelled:
1538 logger.info(
1539 "Elicitation cancelled by client (session %.8s..., id=%s)",
1540 session.session_id,
1541 request_id,
1542 )
1543
1544 def _handle_elicitation_complete(
1545 params: JSONObject,
1546 *,
1547 session: "MCPSession | None",
1548 ) -> None:
1549 """Resolve a pending URL-mode elicitation when the out-of-band flow completes.
1550
1551 The server sends ``notifications/elicitation/complete`` after an external
1552 OAuth / URL flow finishes. The client echoes it here; we resolve the Future
1553 that the tool is awaiting.
1554 """
1555 if session is None:
1556 return
1557 elicitation_id = params.get("elicitationId")
1558 if not isinstance(elicitation_id, str):
1559 return
1560 # URL-mode elicitations use the elicitation_id as the pending key.
1561 from musehub.mcp.session import resolve_elicitation
1562 resolved = resolve_elicitation(session, elicitation_id, {"action": "accept"})
1563 if resolved:
1564 logger.info(
1565 "URL elicitation completed (session %.8s..., id=%s)",
1566 session.session_id,
1567 elicitation_id,
1568 )
1569
1570 def _handle_resources_list() -> JSONObject:
1571 """Return the static resource catalogue."""
1572 import json as _json
1573 raw: list[JSONValue] = _json.loads(_json.dumps(list(STATIC_RESOURCES)))
1574 return {"resources": raw}
1575
1576 def _handle_resources_templates_list() -> JSONObject:
1577 """Return the RFC 6570 URI template catalogue."""
1578 import json as _json
1579 raw: list[JSONValue] = _json.loads(_json.dumps(list(RESOURCE_TEMPLATES)))
1580 return {"resourceTemplates": raw}
1581
1582 async def _handle_resources_read(
1583 params: JSONObject,
1584 *,
1585 user_id: str | None,
1586 ) -> JSONObject:
1587 """Read a ``musehub://`` resource by URI."""
1588 uri = params.get("uri")
1589 if not isinstance(uri, str):
1590 raise _MCPError(_INVALID_PARAMS, "resources/read requires a 'uri' string parameter")
1591
1592 data = await read_resource(uri, user_id=user_id)
1593 text = json.dumps(data, default=str)
1594 contents: list[JSONValue] = [{"uri": uri, "mimeType": "application/json", "text": text}]
1595 return {"contents": contents}
1596
1597 def _handle_prompts_list() -> JSONObject:
1598 """Return the prompt catalogue."""
1599 import json as _json
1600 raw: list[JSONValue] = _json.loads(_json.dumps(list(PROMPT_CATALOGUE)))
1601 return {"prompts": raw}
1602
1603 def _handle_prompts_get(params: JSONObject) -> JSONObject:
1604 """Assemble and return a prompt by name."""
1605 name = params.get("name")
1606 arguments = params.get("arguments") or {}
1607
1608 if not isinstance(name, str):
1609 raise _MCPError(_INVALID_PARAMS, "prompts/get requires a 'name' string parameter")
1610 if name not in PROMPT_NAMES:
1611 raise _MCPError(_METHOD_NOT_FOUND, f"Prompt not found: {name!r}")
1612
1613 args: StrDict = {}
1614 if isinstance(arguments, dict):
1615 for k, v in arguments.items():
1616 if isinstance(v, str):
1617 args[k] = v
1618
1619 prompt_result = get_prompt(name, args)
1620 if prompt_result is None:
1621 raise _MCPError(_METHOD_NOT_FOUND, f"Prompt not found: {name!r}")
1622 import json as _json
1623 resp: JSONObject = _json.loads(_json.dumps(prompt_result))
1624 return resp
1625
1626 # ── JSON-RPC helpers ──────────────────────────────────────────────────────────
1627
1628 def _success(req_id: str | int | None, result: JSONObject) -> JSONObject:
1629 return {"jsonrpc": "2.0", "id": req_id, "result": result}
1630
1631 def _error(
1632 req_id: str | int | None,
1633 code: int,
1634 message: str,
1635 data: JSONValue | None = None,
1636 ) -> JSONObject:
1637 error: JSONObject = {"code": code, "message": message}
1638 if data is not None:
1639 error["data"] = data
1640 return {"jsonrpc": "2.0", "id": req_id, "error": error}
1641
1642 def _tool_error(message: str) -> JSONObject:
1643 """Build a tool-level error (envelope success, isError=true content)."""
1644 return {
1645 "content": [{"type": "text", "text": json.dumps({"error": message})}],
1646 "isError": True,
1647 }
1648
1649 async def dispatch_tool(
1650 name: str,
1651 arguments: JSONObject,
1652 *,
1653 user_id: str = "",
1654 session_context: "MCPSession | None" = None,
1655 ) -> JSONObject:
1656 """Public test-friendly wrapper around ``_call_tool``.
1657
1658 Constructs a minimal ``ToolCallContext`` and delegates to the internal
1659 dispatcher. Use this in tests and CLI integrations that need to invoke
1660 a tool by name without going through the full MCP HTTP layer.
1661
1662 Args:
1663 name: Tool name (e.g. ``"musehub_read_profile_manifest"``).
1664 arguments: Tool input arguments dict.
1665 user_id: Authenticated caller's MSign handle (empty for anonymous).
1666 session_context: Optional ``SessionContext`` (e.g. for repo focus);
1667 pass ``None`` in tests.
1668
1669 Returns:
1670 Standard MCP tool-call result envelope (``content`` + optional
1671 ``isError`` key).
1672 """
1673 from musehub.mcp.context import ToolCallContext
1674
1675 ctx = ToolCallContext(
1676 user_id=user_id,
1677 session=session_context, # type: ignore[arg-type]
1678 is_agent=False,
1679 agent_name=None,
1680 )
1681 try:
1682 return await _call_tool(name, arguments, ctx=ctx)
1683 except Exception as exc:
1684 logger.exception("dispatch_tool error (tool=%s): %s", name, exc)
1685 return _tool_error(f"Internal error executing tool '{name}'")
File History 3 commits
sha256:50b52eda7afb2f122863aef47d684d1a9e4684b48f5f95367fc956e28ceb7d42 refactor: rename merge strategy aliases to canonical names Sonnet 4.6 minor 10 days ago
sha256:d8cbca3a06f39f82f66be6c29de3f41c3dec5f367722958fb5454dcbc007cc15 fix: rc11 test fixes — event→verdict rename, migration coun… Sonnet 4.6 patch 12 days ago
sha256:af9422a68cbd2db7c88f664388e11134b0ae0057ee5ad14465d82208548a9d7d changing --event to --verdict. displaying changes requested… Human minor 12 days ago