gabriel / musehub public
dispatcher.py python
1,703 lines 71.9 KB Cold
Raw
sha256:50b52eda7afb2f122863aef47d684d1a9e4684b48f5f95367fc956e28ceb7d42 refactor: rename merge strategy aliases to canonical names Sonnet 4.6 minor ⚠ breaking 140 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 "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 elif name == "musehub_accept_collaborator_invite":
1092 from musehub.mcp.write_tools.collaborators import execute_accept_collaborator_invite
1093 result = await execute_accept_collaborator_invite(
1094 repo_id=_str("repo_id"),
1095 actor=user_id or "",
1096 )
1097
1098 # ── Webhook management ────────────────────────────────────────────────────
1099
1100 elif name == "musehub_create_webhook":
1101 from musehub.mcp.write_tools.webhooks import execute_create_webhook
1102 result = await execute_create_webhook(
1103 repo_id=_str("repo_id"),
1104 url=_str("url"),
1105 events=_list_str("events"),
1106 secret=_str("secret") or "",
1107 actor=user_id or "",
1108 )
1109 elif name == "musehub_list_webhooks":
1110 from musehub.mcp.write_tools.webhooks import execute_list_webhooks
1111 result = await execute_list_webhooks(
1112 repo_id=_str("repo_id"),
1113 actor=user_id or "",
1114 )
1115 elif name == "musehub_delete_webhook":
1116 from musehub.mcp.write_tools.webhooks import execute_delete_webhook
1117 result = await execute_delete_webhook(
1118 repo_id=_str("repo_id"),
1119 webhook_id=_str("webhook_id"),
1120 actor=user_id or "",
1121 )
1122
1123 # ── Release asset management ──────────────────────────────────────────────
1124
1125 elif name == "musehub_attach_release_asset":
1126 from musehub.mcp.write_tools.releases import execute_attach_release_asset
1127 result = await execute_attach_release_asset(
1128 repo_id=_str("repo_id"),
1129 tag=_str("tag"),
1130 name=_str("name"),
1131 download_url=_str("download_url"),
1132 label=_str("label") or "",
1133 content_type=_str("content_type") or "",
1134 size=_int("size", 0),
1135 actor=user_id or "",
1136 )
1137 elif name == "musehub_delete_release_asset":
1138 from musehub.mcp.write_tools.releases import execute_delete_release_asset
1139 result = await execute_delete_release_asset(
1140 repo_id=_str("repo_id"),
1141 tag=_str("tag"),
1142 asset_id=_str("asset_id"),
1143 actor=user_id or "",
1144 )
1145 elif name == "musehub_list_issue_comments":
1146 result = await exe.execute_list_issue_comments(
1147 repo_id=_str("repo_id"),
1148 issue_number=_int("issue_number"),
1149 limit=_int("limit", 100),
1150 cursor=_str_or_none("cursor"),
1151 )
1152 elif name == "musehub_update_release":
1153 result = await exe.execute_update_release(
1154 repo_id=_str("repo_id"),
1155 tag=_str("tag"),
1156 title=_str_or_none("title"),
1157 body=_str_or_none("body"),
1158 channel=_str_or_none("channel"),
1159 is_draft=arguments.get("is_draft"),
1160 actor=user_id or "",
1161 )
1162 elif name == "musehub_list_release_assets":
1163 result = await exe.execute_list_release_assets(
1164 repo_id=_str("repo_id"),
1165 tag=_str("tag"),
1166 limit=_int("limit", 50),
1167 cursor=_str_or_none("cursor"),
1168 )
1169 elif name == "musehub_read_user_profile":
1170 result = await exe.execute_read_user_profile(username=_str("username"))
1171 elif name == "musehub_update_user_profile":
1172 _pinned = arguments.get("pinned_repo_ids")
1173 result = await exe.execute_update_user_profile(
1174 username=_str("username"),
1175 bio=_str_or_none("bio"),
1176 avatar_url=_str_or_none("avatar_url"),
1177 pinned_repo_ids=list(_pinned) if isinstance(_pinned, list) else None,
1178 actor=user_id or "",
1179 )
1180 elif name == "musehub_create_user_profile":
1181 result = await exe.execute_create_user_profile(
1182 username=_str("username"),
1183 bio=_str_or_none("bio"),
1184 avatar_url=_str_or_none("avatar_url"),
1185 actor=user_id or "",
1186 )
1187 elif name == "musehub_delete_user_account":
1188 result = await exe.execute_delete_user_account(
1189 username=_str("username"),
1190 actor=user_id or "",
1191 )
1192 elif name == "musehub_read_profile_manifest":
1193 result = await exe.execute_read_profile_manifest(handle=_str("handle"))
1194 elif name == "musehub_issue_attestation":
1195 result = await exe.execute_issue_attestation(
1196 attester=_str("attester"),
1197 subject=_str("subject"),
1198 claim=_str("claim"),
1199 issued_at_iso=_str("issued_at_iso"),
1200 signature=_str("signature"),
1201 attester_public_key=_str("attester_public_key"),
1202 )
1203 elif name == "musehub_revoke_attestation":
1204 result = await exe.execute_revoke_attestation(
1205 attestation_id=_str("attestation_id"),
1206 revoker=_str("revoker"),
1207 )
1208 elif name == "musehub_list_attestations":
1209 result = await exe.execute_list_attestations(
1210 subject=_str("subject"),
1211 include_revoked=bool(arguments.get("include_revoked", False)),
1212 )
1213 elif name == "musehub_record_mpay_claim":
1214 result = await exe.execute_record_mpay_claim(
1215 sender=_str("sender"),
1216 recipient=_str("recipient"),
1217 amount_nano=_int("amount_nano", 0),
1218 nonce_hex=_str("nonce_hex"),
1219 signature=_str("signature"),
1220 sender_public_key=_str("sender_public_key"),
1221 memo=_str_or_none("memo"),
1222 )
1223 elif name == "musehub_get_mpay_ledger":
1224 result = await exe.execute_get_mpay_ledger(
1225 handle=_str("handle"),
1226 limit=_int("limit", 100),
1227 )
1228 elif name == "musehub_list_topics":
1229 result = await exe.execute_list_topics(
1230 query=_str_or_none("query"),
1231 limit=_int("limit", 50),
1232 )
1233 elif name == "musehub_set_repo_topics":
1234 _topics = arguments.get("topics")
1235 result = await exe.execute_set_repo_topics(
1236 repo_id=_str("repo_id"),
1237 topics=list(_topics) if isinstance(_topics, list) else [],
1238 actor=user_id or "",
1239 )
1240 elif name == "musehub_list_webhook_deliveries":
1241 result = await exe.execute_list_webhook_deliveries(
1242 repo_id=_str("repo_id"),
1243 webhook_id=_str("webhook_id"),
1244 limit=_int("limit", 20),
1245 cursor=_str_or_none("cursor"),
1246 )
1247 elif name == "musehub_redeliver_webhook":
1248 result = await exe.execute_redeliver_webhook(
1249 repo_id=_str("repo_id"),
1250 webhook_id=_str("webhook_id"),
1251 delivery_id=_str("delivery_id"),
1252 actor=user_id or "",
1253 )
1254
1255 # ── Elicitation-powered tools (MCP 2025-11-25) ────────────────────────────
1256
1257 elif name == "musehub_review_proposal_interactive":
1258 from musehub.mcp.write_tools.elicitation_tools import execute_review_proposal_interactive
1259 result = await execute_review_proposal_interactive(
1260 repo_id=_str("repo_id"),
1261 proposal_id=_str("proposal_id"),
1262 dimension=_str_or_none("dimension"),
1263 depth=_str_or_none("depth"),
1264 ctx=ctx,
1265 )
1266 elif name == "musehub_create_release_interactive":
1267 from musehub.mcp.write_tools.elicitation_tools import execute_create_release_interactive
1268 result = await execute_create_release_interactive(
1269 repo_id=_str("repo_id"),
1270 tag=_str_or_none("tag"),
1271 title=_str_or_none("title"),
1272 notes=_str_or_none("notes"),
1273 ctx=ctx,
1274 )
1275
1276 # ── Muse CLI + auth tools ─────────────────────────────────────────────────
1277
1278 elif name == "musehub_whoami":
1279 result = await exe.execute_whoami(user_id=user_id)
1280 elif name == "musehub_agent_notify":
1281 _raw_payload = arguments.get("payload")
1282 result = exe.execute_agent_notify(
1283 sender_session_id=ctx.session.session_id if ctx.session else "",
1284 sender_handle=user_id,
1285 target_handle=_str("target_handle"),
1286 event=_str("event"),
1287 payload=dict(_raw_payload) if isinstance(_raw_payload, dict) else {},
1288 )
1289 elif name == "musehub_agent_broadcast":
1290 _raw_payload = arguments.get("payload")
1291 result = exe.execute_agent_broadcast(
1292 sender_session_id=ctx.session.session_id if ctx.session else "",
1293 sender_handle=user_id,
1294 event=_str("event"),
1295 payload=dict(_raw_payload) if isinstance(_raw_payload, dict) else {},
1296 repo_focus=ctx.session.repo_focus if ctx.session else None,
1297 )
1298 elif name == "muse_push":
1299 _raw_commits = arguments.get("commits")
1300 _raw_snapshots = arguments.get("snapshots")
1301 _raw_blobs = arguments.get("blobs")
1302 result = await exe.execute_muse_push(
1303 repo_id=_str("repo_id"),
1304 branch=_str("branch"),
1305 head_commit_id=_str("head_commit_id"),
1306 commits=list(_raw_commits) if isinstance(_raw_commits, list) else [],
1307 snapshots=list(_raw_snapshots) if isinstance(_raw_snapshots, list) else None,
1308 blobs=list(_raw_blobs) if isinstance(_raw_blobs, list) else None,
1309 force=_bool("force", False),
1310 user_id=user_id or "",
1311 )
1312 elif name == "muse_pull":
1313 result = await exe.execute_muse_pull(
1314 repo_id=_str("repo_id"),
1315 branch=_str_or_none("branch"),
1316 since_commit_id=_str_or_none("since_commit_id"),
1317 blob_ids=_list_str("blob_ids"),
1318 )
1319 elif name == "muse_remote":
1320 result = await exe.execute_muse_remote(
1321 owner=_str("owner"),
1322 slug=_str("slug"),
1323 ref=_str_or_none("ref"),
1324 )
1325 elif name == "muse_config":
1326 result = await exe.execute_muse_config(
1327 key=_str_or_none("key"),
1328 value=_str_or_none("value"),
1329 )
1330 elif name == "musehub_publish_domain":
1331 _raw_capabilities = arguments.get("capabilities")
1332 result = await exe.execute_musehub_publish_domain(
1333 author_slug=_str("author_slug"),
1334 slug=_str("slug"),
1335 display_name=_str("display_name"),
1336 description=_str("description"),
1337 capabilities=dict(_raw_capabilities) if isinstance(_raw_capabilities, dict) else {},
1338 viewer_type=_str("viewer_type"),
1339 version=_str("version") or "0.1.0",
1340 user_id=user_id or "",
1341 )
1342
1343 # ── Coordination read tools ───────────────────────────────────────────────
1344 elif name == "musehub_read_coord_swarm":
1345 result = await exe.execute_read_coord_swarm(_str("repo_id"))
1346 elif name == "musehub_list_coord_reservations":
1347 result = await exe.execute_list_coord_reservations(
1348 _str("repo_id"),
1349 agent_id=_str_or_none("agent_id"),
1350 include_expired=_bool("include_expired", False),
1351 limit=_int("limit", 200),
1352 )
1353 elif name == "musehub_read_coord_conflicts":
1354 result = await exe.execute_read_coord_conflicts(
1355 _str("repo_id"),
1356 addresses=_list_str("addresses"),
1357 )
1358 elif name == "musehub_list_coord_tasks":
1359 result = await exe.execute_list_coord_tasks(
1360 _str("repo_id"),
1361 queue=_str_or_none("queue"),
1362 status=_str_or_none("status"),
1363 limit=_int("limit", 100),
1364 )
1365
1366 # ── Coordination write tools ──────────────────────────────────────────────
1367 elif name == "musehub_claim_coord_task":
1368 result = await exe.execute_claim_coord_task(
1369 _str("repo_id"),
1370 _str("task_id"),
1371 _str("agent_id"),
1372 user_id=user_id or "",
1373 )
1374 elif name == "musehub_complete_coord_task":
1375 _raw_result = arguments.get("result")
1376 result = await exe.execute_complete_coord_task(
1377 _str("repo_id"),
1378 _str("task_id"),
1379 _str("agent_id"),
1380 result=dict(_raw_result) if isinstance(_raw_result, dict) else None,
1381 )
1382 elif name == "musehub_fail_coord_task":
1383 result = await exe.execute_fail_coord_task(
1384 _str("repo_id"),
1385 _str("task_id"),
1386 _str("agent_id"),
1387 reason=_str("reason"),
1388 )
1389 elif name == "musehub_extend_coord_reservation":
1390 result = await exe.execute_extend_coord_reservation(
1391 _str("repo_id"),
1392 _str("reservation_id"),
1393 extend_by_s=_int("extend_by_s", 300),
1394 )
1395 elif name == "musehub_create_coord_reservation":
1396 _raw_addresses = arguments.get("addresses")
1397 result = await exe.execute_create_coord_reservation(
1398 _str("repo_id"),
1399 addresses=list(_raw_addresses) if isinstance(_raw_addresses, list) else [],
1400 agent_id=_str("agent_id"),
1401 ttl_s=_int("ttl_s", 300),
1402 )
1403 elif name == "musehub_delete_coord_reservation":
1404 result = await exe.execute_delete_coord_reservation(
1405 _str("repo_id"),
1406 reservation_id=_str("reservation_id"),
1407 agent_id=_str("agent_id"),
1408 )
1409 elif name == "musehub_enqueue_coord_task":
1410 _raw_payload = arguments.get("payload")
1411 _raw_depends = arguments.get("depends_on")
1412 result = await exe.execute_enqueue_coord_task(
1413 _str("repo_id"),
1414 queue=_str("queue"),
1415 payload=dict(_raw_payload) if isinstance(_raw_payload, dict) else {},
1416 agent_id=_str("agent_id"),
1417 priority=_int("priority", 50),
1418 depends_on=list(_raw_depends) if isinstance(_raw_depends, list) else None,
1419 )
1420
1421 elif name == "musehub_read_cross_repo_impact":
1422 result = await exe.execute_read_cross_repo_impact(
1423 _str("repo_id"),
1424 _str("address"),
1425 )
1426
1427 elif name == "musehub_read_workspace_intel":
1428 result = await exe.execute_read_workspace_intel(
1429 _str("owner"),
1430 )
1431
1432 # ── Mist tools ────────────────────────────────────────────────────────────
1433 elif name == "muse_mist_read":
1434 result = await exe.execute_read_mist(
1435 _str("mist_id"),
1436 actor=user_id or "",
1437 )
1438 elif name == "muse_mist_list":
1439 result = await exe.execute_list_mists(
1440 _str_or_none("owner"),
1441 artifact_type=_str_or_none("artifact_type"),
1442 include_secret=bool(arguments.get("include_secret", False)),
1443 cursor=_str_or_none("cursor"),
1444 limit=_int("limit", 20),
1445 actor=user_id or "",
1446 )
1447 elif name == "muse_mist_embed":
1448 result = await exe.execute_read_mist_embed(
1449 _str("mist_id"),
1450 owner=_str("owner"),
1451 actor=user_id or "",
1452 )
1453 elif name == "muse_mist_list_forks":
1454 result = await exe.execute_list_mist_forks(
1455 _str("mist_id"),
1456 limit=_int("limit", 20),
1457 actor=user_id or "",
1458 )
1459 elif name == "muse_mist_raw":
1460 result = await exe.execute_read_mist_raw(
1461 _str("mist_id"),
1462 actor=user_id or "",
1463 )
1464 elif name == "muse_mist_create":
1465 from musehub.mcp.write_tools.mists import execute_create_mist
1466 result = await execute_create_mist(
1467 filename=_str("filename"),
1468 content=_str("content"),
1469 actor=user_id or "",
1470 title=_str_or_none("title") or "",
1471 description=_str_or_none("description") or "",
1472 visibility=_str_or_none("visibility") or "public",
1473 tags=_list_str("tags") if "tags" in arguments else None,
1474 agent_id=_str_or_none("agent_id") or "",
1475 model_id=_str_or_none("model_id") or "",
1476 gpg_signature=_str_or_none("gpg_signature"),
1477 )
1478 elif name == "muse_mist_update":
1479 from musehub.mcp.write_tools.mists import execute_update_mist
1480 result = await execute_update_mist(
1481 mist_id=_str("mist_id"),
1482 actor=user_id or "",
1483 title=_str_or_none("title"),
1484 description=_str_or_none("description"),
1485 visibility=_str_or_none("visibility"),
1486 tags=_list_str("tags") if "tags" in arguments else None,
1487 content=_str_or_none("content"),
1488 )
1489 elif name == "muse_mist_fork":
1490 from musehub.mcp.write_tools.mists import execute_fork_mist
1491 result = await execute_fork_mist(
1492 mist_id=_str("mist_id"),
1493 actor=user_id or "",
1494 )
1495 elif name == "muse_mist_delete":
1496 from musehub.mcp.write_tools.mists import execute_delete_mist
1497 result = await execute_delete_mist(
1498 mist_id=_str("mist_id"),
1499 actor=user_id or "",
1500 )
1501
1502 else:
1503 return _tool_error(f"Unknown tool: {name!r}")
1504
1505 # Wrap MusehubToolResult in an MCP content block.
1506 #
1507 # Prompt-injection guard: tool results may contain user-controlled content
1508 # (commit messages, issue bodies, file paths, repository names). We wrap
1509 # the JSON payload in <musehub_tool_result> tags so the model can
1510 # unambiguously distinguish tool data from system instructions. The system
1511 # prompt in prompts.py instructs the model to treat the content inside
1512 # these tags as DATA ONLY and never as instructions.
1513 if result.ok:
1514 text = (
1515 f"<musehub_tool_result>\n"
1516 f"{json.dumps(result.data, default=str)}\n"
1517 f"</musehub_tool_result>"
1518 )
1519 return {
1520 "content": [{"type": "text", "text": text}],
1521 "isError": False,
1522 }
1523 else:
1524 error_payload: JSONObject = {
1525 "error_code": result.error_code,
1526 "error_message": result.error_message,
1527 }
1528 if result.hint:
1529 error_payload["hint"] = result.hint
1530 error_text = json.dumps(error_payload)
1531 return {
1532 "content": [{"type": "text", "text": error_text}],
1533 "isError": True,
1534 }
1535
1536 def _handle_notifications_cancelled(
1537 params: JSONObject,
1538 *,
1539 session: "MCPSession | None",
1540 ) -> None:
1541 """Cancel a pending elicitation Future on client cancellation.
1542
1543 Called when the client sends ``notifications/cancelled`` with the request
1544 ID of an outstanding ``elicitation/create`` request.
1545 """
1546 if session is None:
1547 return
1548 request_id = params.get("requestId")
1549 if request_id is None:
1550 return
1551 from musehub.mcp.session import cancel_elicitation
1552 if not isinstance(request_id, (str, int)):
1553 return
1554 cancelled = cancel_elicitation(session, request_id)
1555 if cancelled:
1556 logger.info(
1557 "Elicitation cancelled by client (session %.8s..., id=%s)",
1558 session.session_id,
1559 request_id,
1560 )
1561
1562 def _handle_elicitation_complete(
1563 params: JSONObject,
1564 *,
1565 session: "MCPSession | None",
1566 ) -> None:
1567 """Resolve a pending URL-mode elicitation when the out-of-band flow completes.
1568
1569 The server sends ``notifications/elicitation/complete`` after an external
1570 OAuth / URL flow finishes. The client echoes it here; we resolve the Future
1571 that the tool is awaiting.
1572 """
1573 if session is None:
1574 return
1575 elicitation_id = params.get("elicitationId")
1576 if not isinstance(elicitation_id, str):
1577 return
1578 # URL-mode elicitations use the elicitation_id as the pending key.
1579 from musehub.mcp.session import resolve_elicitation
1580 resolved = resolve_elicitation(session, elicitation_id, {"action": "accept"})
1581 if resolved:
1582 logger.info(
1583 "URL elicitation completed (session %.8s..., id=%s)",
1584 session.session_id,
1585 elicitation_id,
1586 )
1587
1588 def _handle_resources_list() -> JSONObject:
1589 """Return the static resource catalogue."""
1590 import json as _json
1591 raw: list[JSONValue] = _json.loads(_json.dumps(list(STATIC_RESOURCES)))
1592 return {"resources": raw}
1593
1594 def _handle_resources_templates_list() -> JSONObject:
1595 """Return the RFC 6570 URI template catalogue."""
1596 import json as _json
1597 raw: list[JSONValue] = _json.loads(_json.dumps(list(RESOURCE_TEMPLATES)))
1598 return {"resourceTemplates": raw}
1599
1600 async def _handle_resources_read(
1601 params: JSONObject,
1602 *,
1603 user_id: str | None,
1604 ) -> JSONObject:
1605 """Read a ``musehub://`` resource by URI."""
1606 uri = params.get("uri")
1607 if not isinstance(uri, str):
1608 raise _MCPError(_INVALID_PARAMS, "resources/read requires a 'uri' string parameter")
1609
1610 data = await read_resource(uri, user_id=user_id)
1611 text = json.dumps(data, default=str)
1612 contents: list[JSONValue] = [{"uri": uri, "mimeType": "application/json", "text": text}]
1613 return {"contents": contents}
1614
1615 def _handle_prompts_list() -> JSONObject:
1616 """Return the prompt catalogue."""
1617 import json as _json
1618 raw: list[JSONValue] = _json.loads(_json.dumps(list(PROMPT_CATALOGUE)))
1619 return {"prompts": raw}
1620
1621 def _handle_prompts_get(params: JSONObject) -> JSONObject:
1622 """Assemble and return a prompt by name."""
1623 name = params.get("name")
1624 arguments = params.get("arguments") or {}
1625
1626 if not isinstance(name, str):
1627 raise _MCPError(_INVALID_PARAMS, "prompts/get requires a 'name' string parameter")
1628 if name not in PROMPT_NAMES:
1629 raise _MCPError(_METHOD_NOT_FOUND, f"Prompt not found: {name!r}")
1630
1631 args: StrDict = {}
1632 if isinstance(arguments, dict):
1633 for k, v in arguments.items():
1634 if isinstance(v, str):
1635 args[k] = v
1636
1637 prompt_result = get_prompt(name, args)
1638 if prompt_result is None:
1639 raise _MCPError(_METHOD_NOT_FOUND, f"Prompt not found: {name!r}")
1640 import json as _json
1641 resp: JSONObject = _json.loads(_json.dumps(prompt_result))
1642 return resp
1643
1644 # ── JSON-RPC helpers ──────────────────────────────────────────────────────────
1645
1646 def _success(req_id: str | int | None, result: JSONObject) -> JSONObject:
1647 return {"jsonrpc": "2.0", "id": req_id, "result": result}
1648
1649 def _error(
1650 req_id: str | int | None,
1651 code: int,
1652 message: str,
1653 data: JSONValue | None = None,
1654 ) -> JSONObject:
1655 error: JSONObject = {"code": code, "message": message}
1656 if data is not None:
1657 error["data"] = data
1658 return {"jsonrpc": "2.0", "id": req_id, "error": error}
1659
1660 def _tool_error(message: str) -> JSONObject:
1661 """Build a tool-level error (envelope success, isError=true content)."""
1662 return {
1663 "content": [{"type": "text", "text": json.dumps({"error": message})}],
1664 "isError": True,
1665 }
1666
1667 async def dispatch_tool(
1668 name: str,
1669 arguments: JSONObject,
1670 *,
1671 user_id: str = "",
1672 session_context: "MCPSession | None" = None,
1673 ) -> JSONObject:
1674 """Public test-friendly wrapper around ``_call_tool``.
1675
1676 Constructs a minimal ``ToolCallContext`` and delegates to the internal
1677 dispatcher. Use this in tests and CLI integrations that need to invoke
1678 a tool by name without going through the full MCP HTTP layer.
1679
1680 Args:
1681 name: Tool name (e.g. ``"musehub_read_profile_manifest"``).
1682 arguments: Tool input arguments dict.
1683 user_id: Authenticated caller's MSign handle (empty for anonymous).
1684 session_context: Optional ``SessionContext`` (e.g. for repo focus);
1685 pass ``None`` in tests.
1686
1687 Returns:
1688 Standard MCP tool-call result envelope (``content`` + optional
1689 ``isError`` key).
1690 """
1691 from musehub.mcp.context import ToolCallContext
1692
1693 ctx = ToolCallContext(
1694 user_id=user_id,
1695 session=session_context, # type: ignore[arg-type]
1696 is_agent=False,
1697 agent_name=None,
1698 )
1699 try:
1700 return await _call_tool(name, arguments, ctx=ctx)
1701 except Exception as exc:
1702 logger.exception("dispatch_tool error (tool=%s): %s", name, exc)
1703 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 ⚠ 140 days ago
sha256:d8cbca3a06f39f82f66be6c29de3f41c3dec5f367722958fb5454dcbc007cc15 fix: rc11 test fixes — event→verdict rename, migration coun… Sonnet 4.6 patch 142 days ago
sha256:af9422a68cbd2db7c88f664388e11134b0ae0057ee5ad14465d82208548a9d7d changing --event to --verdict. displaying changes requested… Human minor ⚠ 142 days ago