Add MCP routes to a Dash app.
(app: Dash, mcp_path: str)
| 48 | |
| 49 | |
| 50 | def enable_mcp_server(app: Dash, mcp_path: str) -> None: |
| 51 | """Add MCP routes to a Dash app.""" |
| 52 | |
| 53 | def _get_or_create_session_id() -> str: |
| 54 | """ |
| 55 | Creates a shared session ID shared across all clients. The session is |
| 56 | used to notify clients of app restarts so they can refresh their view |
| 57 | of the app. |
| 58 | When hot-reloading is enabled, the reload_hash is used |
| 59 | Otherwise, the parent PID is used because it is a stable identifier |
| 60 | across different worker processes. |
| 61 | """ |
| 62 | # pylint: disable=protected-access |
| 63 | reload_hash = app._hot_reload.hash |
| 64 | if reload_hash is not None: |
| 65 | return reload_hash |
| 66 | return hashlib.sha256(f"dash-mcp-{os.getppid()}".encode()).hexdigest()[:32] |
| 67 | |
| 68 | _session_id: str = _get_or_create_session_id() |
| 69 | |
| 70 | def _is_session_stale(client_session_id: str | None) -> bool: |
| 71 | """True when the client's session doesn't match or the hash changed.""" |
| 72 | if client_session_id != _session_id: |
| 73 | return True |
| 74 | # pylint: disable=protected-access |
| 75 | reload_hash = app._hot_reload.hash |
| 76 | if reload_hash is None: |
| 77 | return False |
| 78 | return reload_hash != _session_id |
| 79 | |
| 80 | # -- Streamable HTTP endpoint -------------------------------------------- |
| 81 | |
| 82 | def _check_session(method: str) -> bool: |
| 83 | """Validate the session header. |
| 84 | |
| 85 | Raises ``ValueError`` when the header is missing. |
| 86 | Returns ``True`` when the session was stale and transparently |
| 87 | recovered, or ``False`` when the session is valid. |
| 88 | """ |
| 89 | nonlocal _session_id |
| 90 | adapter = app.backend.request_adapter() |
| 91 | if method == "initialize": |
| 92 | _session_id = _get_or_create_session_id() |
| 93 | return False |
| 94 | client_session_id = adapter.headers.get("Mcp-Session-Id") |
| 95 | if client_session_id and _is_session_stale(client_session_id): |
| 96 | _session_id = _get_or_create_session_id() |
| 97 | logger.debug("MCP session recovered: %s", _session_id) |
| 98 | return True |
| 99 | return False |
| 100 | |
| 101 | def _json_response(*messages: dict): |
| 102 | """Wrap one or more JSON-RPC messages in a response. |
| 103 | |
| 104 | A single message is serialised as a JSON object; multiple |
| 105 | messages are serialised as a JSON array. |
| 106 | """ |
| 107 | body = messages[0] if len(messages) == 1 else list(messages) |
no test coverage detected
searching dependent graphs…