gabriel / musehub public
context.py python
255 lines 9.6 KB
Raw
sha256:0997d6250ae6476362f6fe2025af7789f46d03df3e9f34356d5e8ee79b201923 fix(issues): use issue number as pagination cursor, not cre… Sonnet 4.6 patch 8 days ago
1 """MCP Tool Call Context — passes session state into elicitation-aware tools.
2
3 ``ToolCallContext`` is created by the dispatcher for every ``tools/call``
4 invocation and passed into tools that support elicitation or progress
5 streaming. Tools that don't use these features receive the context too but
6 may ignore it.
7
8 Elicitation flow:
9 1. Tool calls ``await ctx.elicit_form(schema_key, message)``.
10 2. Context generates a unique request ID and creates an ``asyncio.Future``
11 in the session's ``pending`` registry.
12 3. Context pushes an ``elicitation/create`` SSE event into the session's
13 SSE queues (the POST response is already an open SSE stream at this point).
14 4. The client receives the event, shows the form/URL to the user, then
15 POSTs the ``elicitation/create`` response back to ``POST /mcp``.
16 5. The dispatcher sees the response, calls ``resolve_elicitation()``.
17 6. The awaited Future resolves and the tool receives the user's data.
18
19 Progress flow:
20 Tool calls ``await ctx.progress(token, value, total, label)``.
21 This pushes a ``notifications/progress`` SSE event without blocking.
22 """
23
24 import asyncio
25 import logging
26 import secrets
27 from dataclasses import dataclass, field
28
29 from musehub.types.json_types import JSONObject, JSONValue
30 from musehub.mcp.session import (
31 MCPSession,
32 cancel_elicitation,
33 create_pending_elicitation,
34 push_to_session,
35 )
36 from musehub.mcp.sse import sse_notification, sse_request
37
38 logger = logging.getLogger(__name__)
39
40 # Timeout for elicitation responses: 5 minutes. After this the tool receives
41 # None and should handle gracefully (skip or return partial result).
42 _ELICITATION_TIMEOUT_SECONDS = 300.0
43
44
45 @dataclass
46 class ToolCallContext:
47 """Runtime context for an MCP ``tools/call`` invocation.
48
49 Carries session state and provides high-level async helpers for
50 elicitation and progress reporting. Passed to all tool executors;
51 tools that don't use it can accept and ignore it.
52
53 Attributes:
54 user_id: Authenticated user from the MSign handle, or ``None``.
55 session: Active :class:`~musehub.mcp.session.MCPSession`, or ``None``
56 for clients that did not send an ``Mcp-Session-Id`` header. When
57 ``None``, elicitation is unavailable and progress events are silent.
58 is_agent: ``True`` when the identity has ``identity_type == "agent"``. Agent
59 callers receive higher rate limits and appear with an "agent" badge
60 in the activity feed.
61 agent_name: Optional display identifier from the ``agent_name`` identity field
62 (e.g. ``"my-bot/1.0"``). ``None`` for human callers.
63 _elicitation_counter: Monotonic counter for unique elicitation IDs.
64 """
65
66 user_id: str | None
67 session: MCPSession | None
68 is_agent: bool = False
69 agent_name: str | None = None
70 _elicitation_counter: int = field(default=0, init=False)
71
72 # ── Public API ────────────────────────────────────────────────────────────
73
74 async def elicit_form(
75 self,
76 schema: JSONObject,
77 message: str,
78 ) -> JSONObject | None:
79 """Request structured data from the user via form-mode elicitation.
80
81 Sends an ``elicitation/create`` request with ``mode: "form"`` to the
82 client via the session's SSE stream, then awaits the user's response.
83
84 Args:
85 schema: A restricted JSON Schema object (flat, primitive properties
86 only) describing the fields to collect. See the elicitation
87 module for pre-built musical schemas.
88 message: Human-readable explanation shown to the user in the client.
89
90 Returns:
91 The ``content`` dict from the accepted elicitation response, or
92 ``None`` if the user declined, cancelled, or the session timed out.
93
94 Raises:
95 RuntimeError: If no session is attached (no ``Mcp-Session-Id``).
96 """
97 if self.session is None:
98 logger.warning("elicit_form called without an active session — returning None")
99 return None
100
101 if not self.session.supports_elicitation_form():
102 logger.warning(
103 "Client does not support form elicitation (session %.8s...) — returning None",
104 self.session.session_id,
105 )
106 return None
107
108 req_id = self._next_elicitation_id()
109 fut = create_pending_elicitation(self.session, req_id)
110
111 params: JSONObject = {
112 "mode": "form",
113 "message": message,
114 "requestedSchema": schema,
115 }
116 event_text = sse_request(req_id, "elicitation/create", params)
117 push_to_session(self.session, event_text)
118
119 try:
120 result = await asyncio.wait_for(
121 asyncio.shield(fut), timeout=_ELICITATION_TIMEOUT_SECONDS
122 )
123 except asyncio.TimeoutError:
124 cancel_elicitation(self.session, req_id)
125 logger.info(
126 "Elicitation timed out for session %.8s...", self.session.session_id
127 )
128 return None
129 except asyncio.CancelledError:
130 return None
131
132 action = result.get("action")
133 if action != "accept":
134 return None
135 content = result.get("content")
136 return content if isinstance(content, dict) else {}
137
138 async def elicit_url(
139 self,
140 url: str,
141 message: str,
142 elicitation_id: str | None = None,
143 ) -> bool:
144 """Direct the user to an external URL for out-of-band interaction.
145
146 Sends an ``elicitation/create`` request with ``mode: "url"`` to the
147 client. Returns ``True`` when the user accepts (clicks through), or
148 ``False`` on decline/cancel/timeout.
149
150 Typical use cases: OAuth to connect a DAW cloud account or streaming
151 platform, payment flows, API key entry on a trusted branded page.
152
153 Args:
154 url: The URL to open. Must be HTTPS in production.
155 message: Human-readable reason shown to the user.
156 elicitation_id: Stable ID used in ``notifications/elicitation/complete``.
157 Auto-generated if not provided.
158
159 Returns:
160 ``True`` if the user accepted, ``False`` otherwise.
161 """
162 if self.session is None:
163 logger.warning("elicit_url called without an active session — returning False")
164 return False
165
166 if not self.session.supports_elicitation_url():
167 logger.warning(
168 "Client does not support URL elicitation (session %.8s...) — returning False",
169 self.session.session_id,
170 )
171 return False
172
173 if elicitation_id is None:
174 elicitation_id = secrets.token_urlsafe(16)
175
176 req_id = self._next_elicitation_id()
177 fut = create_pending_elicitation(self.session, req_id)
178
179 params: JSONObject = {
180 "mode": "url",
181 "message": message,
182 "url": url,
183 "elicitationId": elicitation_id,
184 }
185 event_text = sse_request(req_id, "elicitation/create", params)
186 push_to_session(self.session, event_text)
187
188 try:
189 result = await asyncio.wait_for(
190 asyncio.shield(fut), timeout=_ELICITATION_TIMEOUT_SECONDS
191 )
192 except asyncio.TimeoutError:
193 cancel_elicitation(self.session, req_id)
194 return False
195 except asyncio.CancelledError:
196 return False
197
198 return result.get("action") == "accept"
199
200 async def progress(
201 self,
202 token: str,
203 value: int | float,
204 total: int | float | None = None,
205 label: str | None = None,
206 ) -> None:
207 """Emit a ``notifications/progress`` event to the client.
208
209 Silent no-op when no session is attached (stateless clients).
210
211 Args:
212 token: Progress token identifying this operation (from the original
213 ``tools/call`` ``_meta.progressToken`` if provided, else a
214 tool-generated string).
215 value: Current progress value.
216 total: Optional total (enables percentage calculation in clients).
217 label: Optional human-readable status message.
218 """
219 if self.session is None:
220 return
221
222 params: JSONObject = {
223 "progressToken": token,
224 "progress": value,
225 }
226 if total is not None:
227 params["total"] = total
228 if label is not None:
229 params["message"] = label
230
231 event_text = sse_notification("notifications/progress", params)
232 push_to_session(self.session, event_text)
233
234 # ── Internal ──────────────────────────────────────────────────────────────
235
236 def _next_elicitation_id(self) -> str:
237 self._elicitation_counter += 1
238 return f"elicit-{self._elicitation_counter}-{secrets.token_hex(4)}"
239
240 @property
241 def has_session(self) -> bool:
242 """True if a session is attached and SSE-based features are available."""
243 return self.session is not None
244
245 @property
246 def has_elicitation(self) -> bool:
247 """True if the client supports at least form-mode elicitation."""
248 return self.session is not None and self.session.supports_elicitation_form()
249
250 @property
251 def actor_label(self) -> str:
252 """Human-readable label for the caller — used in event metadata."""
253 if self.is_agent:
254 return self.agent_name or "agent"
255 return self.user_id or "anonymous"
File History 1 commit
sha256:0997d6250ae6476362f6fe2025af7789f46d03df3e9f34356d5e8ee79b201923 fix(issues): use issue number as pagination cursor, not cre… Sonnet 4.6 patch 8 days ago