open-edison 0.1.19__py3-none-any.whl → 0.1.26__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: open-edison
3
- Version: 0.1.19
3
+ Version: 0.1.26
4
4
  Summary: Open-source MCP security, aggregation, and monitoring. Single-user, self-hosted MCP proxy.
5
5
  Author-email: Hugo Berg <hugo@edison.watch>
6
6
  License-File: LICENSE
@@ -25,24 +25,40 @@ Requires-Dist: pytest>=8.3.3; extra == 'dev'
25
25
  Requires-Dist: ruff>=0.12.3; extra == 'dev'
26
26
  Description-Content-Type: text/markdown
27
27
 
28
- # OpenEdison
28
+ # OpenEdison 🔒⚡️
29
29
 
30
- Open-source single-user MCP security gateway that prevents data exfiltration—via direct access or tool chaining—with full monitoring for local single‑user deployments. Provides core functionality of <https://edison.watch> for local use.
30
+ MCP security gateway that prevents data exfiltration—via direct access or tool chaining—with full monitoring for local single‑user deployments. Provides core functionality of <https://edison.watch> for local use.
31
+
32
+ <p align="center">
33
+ <img src="media/trifecta520p.gif" alt="Trifecta Security Risk Animation" width="520">
34
+ </p>
31
35
 
32
36
  <div align="center">
33
- <h2>📧 Interested in connecting AI to your business software with proper access controls? <a href="mailto:hello@edison.watch">Contact us</a> to discuss.</h2>
37
+ <h2>📧 To get visibility, control and exfiltration blocker into AI's interaction with your company software, systems of record, DBs, <a href="mailto:hello@edison.watch">Contact us</a> to discuss.</h2>
34
38
  </div>
35
39
 
36
- ## Features
40
+ <p align="center">
41
+ <img alt="Project Version" src="https://img.shields.io/pypi/v/open-edison?label=version&color=blue">
42
+ <img alt="Python Version" src="https://img.shields.io/badge/python-3.12-blue?logo=python">
43
+ <img src="https://img.shields.io/badge/License-GPLv3-blue" alt="License">
44
+
45
+
46
+ </p>
47
+
48
+ ---
49
+
50
+
51
+ ## Features ✨
37
52
 
38
- - **Single-user MCP proxy** - No multi-user complexity, just a simple proxy for your MCP servers
39
- - **JSON configuration** - Easy to configure and manage your MCP servers
40
- - **Simple local frontend** - Track and monitor your MCP interactions, servers, and sessions.
41
- - **Session tracking** - Track and monitor your MCP interactions
42
- - **Simple API** - REST API for managing MCP servers and proxying requests
43
- - **Docker support** - Run in a container for easy deployment
53
+ - 🛑 **Prevent Data Leaks** - Edison automatically blocks any data leaks, even if your AI gets jailbroken
54
+ - 👤 **Single-user MCP proxy** - No multi-user complexity, just a simple proxy for your MCP servers
55
+ - 🗂️ **JSON configuration** - Easy to configure and manage your MCP servers
56
+ - 🖥️ **Simple local frontend** - Track and monitor your MCP interactions, servers, and sessions.
57
+ - 📊 **Session tracking** - Track and monitor your MCP interactions
58
+ - 🔗 **Simple API** - REST API for managing MCP servers and proxying requests
59
+ - 🐳 **Docker support** - Run in a container for easy deployment
44
60
 
45
- ## Quick Start
61
+ ## Quick Start 🚀
46
62
 
47
63
  The fastest way to get started:
48
64
 
@@ -55,7 +71,7 @@ curl -fsSL https://raw.githubusercontent.com/Edison-Watch/open-edison/main/curl_
55
71
  Run locally with uvx: `uvx open-edison --config-dir ~/edison-config`
56
72
 
57
73
  <details>
58
- <summary>Install Node.js/npm (optional for MCP tools)</summary>
74
+ <summary>⬇️ Install Node.js/npm (optional for MCP tools)</summary>
59
75
 
60
76
  If you need `npx` (for Node-based MCP tools like `mcp-remote`), install Node.js as well:
61
77
 
@@ -75,6 +91,7 @@ If you need `npx` (for Node-based MCP tools like `mcp-remote`), install Node.js
75
91
  - Node/npx: `winget install -e --id OpenJS.NodeJS`
76
92
 
77
93
  After installation, ensure that `npx` is available on PATH.
94
+ </details>
78
95
 
79
96
  <details>
80
97
  <summary><img src="https://img.shields.io/badge/pypi-3775A9?style=for-the-badge&logo=pypi&logoColor=white" alt="PyPI"> Install from PyPI</summary>
@@ -109,18 +126,18 @@ There is a dockerfile for simple local setup.
109
126
 
110
127
  ```bash
111
128
  # Single-line:
112
- git clone https://github.com/GatlingX/open-edison.git && cd open-edison && make docker_run
129
+ git clone https://github.com/Edison-Watch/open-edison.git && cd open-edison && make docker_run
113
130
 
114
131
  # Or
115
132
  # Clone repo
116
- git clone https://github.com/GatlingX/open-edison.git
133
+ git clone https://github.com/Edison-Watch/open-edison.git
117
134
  # Enter repo
118
135
  cd open-edison
119
136
  # Build and run
120
137
  make docker_run
121
138
  ```
122
139
 
123
- The MCP server will be available at `http://localhost:3000` and the api + frontend at `http://localhost:3001`.
140
+ The MCP server will be available at `http://localhost:3000` and the api + frontend at `http://localhost:3001`. 🌐
124
141
 
125
142
  </details>
126
143
 
@@ -130,7 +147,7 @@ The MCP server will be available at `http://localhost:3000` and the api + fronte
130
147
  1. Clone the repository:
131
148
 
132
149
  ```bash
133
- git clone https://github.com/GatlingX/open-edison.git
150
+ git clone https://github.com/Edison-Watch/open-edison.git
134
151
  cd open-edison
135
152
  ```
136
153
 
@@ -161,12 +178,12 @@ make run
161
178
  open-edison run
162
179
  ```
163
180
 
164
- The server will be available at `http://localhost:3000`.
181
+ The server will be available at `http://localhost:3000`. 🌐
165
182
 
166
183
  </details>
167
184
 
168
185
  <details>
169
- <summary>MCP Connection</summary>
186
+ <summary>🔌 MCP Connection</summary>
170
187
 
171
188
  Connect any MCP client to Open Edison (requires Node.js/npm for `npx`):
172
189
 
@@ -190,20 +207,20 @@ Or add to your MCP client config:
190
207
  </details>
191
208
 
192
209
  <details>
193
- <summary>Usage</summary>
210
+ <summary>🧭 Usage</summary>
194
211
 
195
212
  ### API Endpoints
196
213
 
197
214
  See [API Reference](docs/quick-reference/api_reference.md) for full API documentation.
198
215
 
199
216
  <details>
200
- <summary>Development</summary>
217
+ <summary>🛠️ Development</summary>
201
218
 
202
- ### Setup
219
+ ### Setup 🧰
203
220
 
204
221
  Setup from source as above.
205
222
 
206
- ### Run
223
+ ### Run ▶️
207
224
 
208
225
  Server doesn't have any auto-reload at the moment, so you'll need to run & ctrl-c this during development.
209
226
 
@@ -211,7 +228,7 @@ Server doesn't have any auto-reload at the moment, so you'll need to run & ctrl-
211
228
  make run
212
229
  ```
213
230
 
214
- ### Tests/code quality
231
+ ### Tests/code quality
215
232
 
216
233
  We expect `make ci` to return cleanly.
217
234
 
@@ -224,7 +241,7 @@ make ci
224
241
  <details>
225
242
  <summary>⚙️ Configuration (config.json)</summary>
226
243
 
227
- ## Configuration
244
+ ## Configuration ⚙️
228
245
 
229
246
  The `config.json` file contains all configuration:
230
247
 
@@ -246,18 +263,20 @@ Each MCP server configuration includes:
246
263
 
247
264
  </details>
248
265
 
266
+ ## 🔐 How Edison prevents data leakages
267
+
249
268
  <details>
250
- <summary>Security & Permissions System</summary>
269
+ <summary>🔱 The lethal trifecta, agent lifecycle management</summary>
251
270
 
252
271
  Open Edison includes a comprehensive security monitoring system that tracks the "lethal trifecta" of AI agent risks, as described in [Simon Willison's blog post](https://simonwillison.net/2025/Jun/16/the-lethal-trifecta/):
253
272
 
254
- <img src="media/lethal-trifecta.png" alt="The lethal trifecta diagram showing the three key AI agent security risks" width="30%">
273
+ <img src="media/lethal-trifecta.png" alt="The lethal trifecta diagram showing the three key AI agent security risks" width="70%">
255
274
 
256
275
  1. **Private data access** - Access to sensitive local files/data
257
276
  2. **Untrusted content exposure** - Exposure to external/web content
258
277
  3. **External communication** - Ability to write/send data externally
259
278
 
260
- <img src="media/pam-diagram.png" alt="Privileged Access Management (PAM) example showing the lethal trifecta in action" width="60%">
279
+ <img src="media/pam-diagram.png" alt="Privileged Access Management (PAM) example showing the lethal trifecta in action" width="90%">
261
280
 
262
281
  The configuration allows you to classify these risks across **tools**, **resources**, and **prompts** using separate configuration files.
263
282
 
@@ -265,7 +284,7 @@ In addition to trifecta, we track Access Control Level (ACL) for each tool call,
265
284
  that is, each tool has an ACL level (one of PUBLIC, PRIVATE, or SECRET), and we track the highest ACL level for each session.
266
285
  If a write operation is attempted to a lower ACL level, it is blocked.
267
286
 
268
- ### Tool Permissions (`tool_permissions.json`)
287
+ ### 🧰 Tool Permissions (`tool_permissions.json`)
269
288
 
270
289
  Defines security classifications for MCP tools. See full file: [tool_permissions.json](tool_permissions.json), it looks like:
271
290
 
@@ -283,7 +302,7 @@ Defines security classifications for MCP tools. See full file: [tool_permissions
283
302
  ```
284
303
 
285
304
  <details>
286
- <summary>Resource Permissions (`resource_permissions.json`)</summary>
305
+ <summary>📁 Resource Permissions (`resource_permissions.json`)</summary>
287
306
 
288
307
  ### Resource Permissions (`resource_permissions.json`)
289
308
 
@@ -299,7 +318,7 @@ Defines security classifications for resource access patterns. See full file: [r
299
318
  </details>
300
319
 
301
320
  <details>
302
- <summary>Prompt Permissions (`prompt_permissions.json`)</summary>
321
+ <summary>💬 Prompt Permissions (`prompt_permissions.json`)</summary>
303
322
 
304
323
  ### Prompt Permissions (`prompt_permissions.json`)
305
324
 
@@ -314,7 +333,7 @@ Defines security classifications for prompt types. See full file: [prompt_permis
314
333
 
315
334
  </details>
316
335
 
317
- ### Wildcard Patterns
336
+ ### Wildcard Patterns
318
337
 
319
338
  All permission types support wildcard patterns:
320
339
 
@@ -322,7 +341,7 @@ All permission types support wildcard patterns:
322
341
  - **Resources**: `scheme:*` (e.g., `file:*` matches all file resources)
323
342
  - **Prompts**: `type:*` (e.g., `template:*` matches all template prompts)
324
343
 
325
- ### Security Monitoring
344
+ ### Security Monitoring 🕵️
326
345
 
327
346
  **All items must be explicitly configured** - unknown tools/resources/prompts will be rejected for security.
328
347
 
@@ -330,20 +349,20 @@ Use the `get_security_status` tool to monitor your session's current risk level
330
349
 
331
350
  </details>
332
351
 
333
- <details>
334
- <summary>Documentation</summary>
352
+
353
+
354
+ ## Documentation 📚
335
355
 
336
356
  📚 **Complete documentation available in [`docs/`](docs/)**
337
357
 
338
- - **[Getting Started](docs/quick-reference/config_quick_start.md)** - Quick setup guide
339
- - **[Configuration](docs/core/configuration.md)** - Complete configuration reference
340
- - **[API Reference](docs/quick-reference/api_reference.md)** - REST API documentation
341
- - **[Development Guide](docs/development/development_guide.md)** - Contributing and development
358
+ - 🚀 **[Getting Started](docs/quick-reference/config_quick_start.md)** - Quick setup guide
359
+ - ⚙️ **[Configuration](docs/core/configuration.md)** - Complete configuration reference
360
+ - 📡 **[API Reference](docs/quick-reference/api_reference.md)** - REST API documentation
361
+ - 🧑‍💻 **[Development Guide](docs/development/development_guide.md)** - Contributing and development
342
362
 
343
- </details>
344
363
 
345
364
  <details>
346
- <summary>License</summary>
365
+ <summary>📄 License</summary>
347
366
 
348
367
  GPL-3.0 License - see [LICENSE](LICENSE) for details.
349
368
 
@@ -0,0 +1,17 @@
1
+ src/__init__.py,sha256=QWeZdjAm2D2B0eWhd8m2-DPpWvIP26KcNJxwEoU1oEQ,254
2
+ src/__main__.py,sha256=kQsaVyzRa_ESC57JpKDSQJAHExuXme0rM5beJsYxFeA,161
3
+ src/cli.py,sha256=_F1xtUU2h4snWUHf1NptRWGQaD2OSIhEPGLh9Rzmtis,10032
4
+ src/config.py,sha256=jZYX4q09hg2VlLCq7FIKa_bL7NpNNMStYMQyEMZPrDg,9910
5
+ src/events.py,sha256=rBH7rnaSWZ7GIC8zyBTwpcvIKWmKYCki-DNGgJhxPow,5001
6
+ src/oauth_manager.py,sha256=qcQa5BDRZr4bjqiXNflCnrXOh9mo9JVjvP2Caseg2Uc,9943
7
+ src/permissions.py,sha256=kIbLPtaJAwV5CF-YECLhU7HEF704LdQCI2xIh-TCu4I,10834
8
+ src/server.py,sha256=Gg2DnnvZr59e0PGtRXVD94fxVxtiNnizlIrVkeLGIok,44504
9
+ src/single_user_mcp.py,sha256=bbWbuuBxjL0DJY9kAHtHt8jwQNePXPa4cD4gnbD9XmE,16897
10
+ src/telemetry.py,sha256=-RZPIjpI53zbsKmp-63REeZ1JirWHV5WvpSRa2nqZEk,11321
11
+ src/middleware/data_access_tracker.py,sha256=bArBffWgYmvxOx9z_pgXQhogvnWQcc1m6WvEblDD4gw,15039
12
+ src/middleware/session_tracking.py,sha256=p3UMruIe5RBYnfAzmSaHMOeN3xKN-9EiCSd7nAGjgrw,22138
13
+ open_edison-0.1.26.dist-info/METADATA,sha256=Tfw-vOv_KlIvUBMcU5DjwHNiksFxJejJBurC0dp_b4Q,11685
14
+ open_edison-0.1.26.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
15
+ open_edison-0.1.26.dist-info/entry_points.txt,sha256=qNAkJcnoTXRhj8J--3PDmXz_TQKdB8H_0C9wiCtDIyA,72
16
+ open_edison-0.1.26.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
17
+ open_edison-0.1.26.dist-info/RECORD,,
src/cli.py CHANGED
@@ -177,6 +177,7 @@ def _spawn_frontend_dev( # noqa: C901 - pragmatic complexity for env probing
177
177
 
178
178
 
179
179
  async def _run_server(args: Any) -> None:
180
+ # TODO check this works as we want it to
180
181
  # Resolve config dir and expose via env for the rest of the app
181
182
  config_dir_arg = getattr(args, "config_dir", None)
182
183
  if config_dir_arg is not None:
@@ -184,7 +185,7 @@ async def _run_server(args: Any) -> None:
184
185
  config_dir = get_config_dir()
185
186
 
186
187
  # Load config after setting env override
187
- cfg = Config.load()
188
+ cfg = Config(config_dir)
188
189
 
189
190
  host = getattr(args, "host", None) or cfg.server.host
190
191
  port = getattr(args, "port", None) or cfg.server.port
src/config.py CHANGED
@@ -58,12 +58,6 @@ def get_config_dir() -> Path:
58
58
  return (Path.home() / ".open-edison").resolve()
59
59
 
60
60
 
61
- # Back-compat private alias (internal modules may import this)
62
- def _get_config_dir() -> Path: # noqa: D401
63
- """Alias to public get_config_dir (maintained for internal imports)."""
64
- return get_config_dir()
65
-
66
-
67
61
  def _default_config_path() -> Path:
68
62
  """Determine default config.json path.
69
63
 
@@ -75,22 +69,18 @@ def _default_config_path() -> Path:
75
69
  repo_config = root_dir / "config.json"
76
70
 
77
71
  # If pyproject.toml exists next to src/, we are likely in a repo checkout
72
+ # Prefer the user config directory if a config already exists there (runtime source of truth),
73
+ # otherwise fall back to the repo copy.
78
74
  if repo_pyproject.exists():
75
+ user_cfg = get_config_dir() / "config.json"
76
+ if user_cfg.exists():
77
+ return user_cfg
79
78
  return repo_config
80
79
 
81
- # Otherwise, prefer user config directory
80
+ # Installed package: prefer user config directory
82
81
  return get_config_dir() / "config.json"
83
82
 
84
83
 
85
- class ConfigError(Exception):
86
- """Exception raised for configuration-related errors"""
87
-
88
- def __init__(self, message: str, config_path: Path | None = None):
89
- self.message = message
90
- self.config_path = config_path
91
- super().__init__(self.message)
92
-
93
-
94
84
  @dataclass
95
85
  class ServerConfig:
96
86
  """Server configuration"""
@@ -119,10 +109,41 @@ class MCPServerConfig:
119
109
  enabled: bool = True
120
110
  roots: list[str] | None = None
121
111
 
112
+ oauth_scopes: list[str] | None = None
113
+ """OAuth scopes to request for this server."""
114
+
115
+ oauth_client_name: str | None = None
116
+ """Custom client name for OAuth registration."""
117
+
122
118
  def __post_init__(self):
123
119
  if self.env is None:
124
120
  self.env = {}
125
121
 
122
+ def is_remote_server(self) -> bool:
123
+ """
124
+ Check if this is a remote MCP server (connects to external HTTPS endpoint).
125
+
126
+ Remote servers use mcp-remote with HTTPS URLs and may require OAuth.
127
+ Local servers run as child processes and don't need OAuth.
128
+ """
129
+ return (
130
+ self.command == "npx"
131
+ and len(self.args) >= 3
132
+ and self.args[1] == "mcp-remote"
133
+ and self.args[2].startswith("https://")
134
+ )
135
+
136
+ def get_remote_url(self) -> str | None:
137
+ """
138
+ Get the remote URL for a remote MCP server.
139
+
140
+ Returns:
141
+ The HTTPS URL if this is a remote server, None otherwise
142
+ """
143
+ if self.is_remote_server():
144
+ return self.args[2]
145
+ return None
146
+
126
147
 
127
148
  @dataclass
128
149
  class TelemetryConfig:
@@ -160,8 +181,7 @@ class Config:
160
181
  log.warning(f"Failed to read version from pyproject.toml: {e}")
161
182
  return "unknown"
162
183
 
163
- @classmethod
164
- def load(cls, config_path: Path | None = None) -> "Config":
184
+ def __init__(self, config_path: Path | None = None) -> None:
165
185
  """Load configuration from JSON file.
166
186
 
167
187
  If a directory path is provided, will look for `config.json` inside it.
@@ -174,11 +194,12 @@ class Config:
174
194
  if config_path.is_dir():
175
195
  config_path = config_path / "config.json"
176
196
 
197
+ log.info(f"Loading configuration from {config_path}")
198
+
177
199
  if not config_path.exists():
178
200
  log.warning(f"Config file not found at {config_path}, creating default config")
179
- default_config = cls.create_default()
180
- default_config.save(config_path)
181
- return default_config
201
+ self.create_default()
202
+ self.save(config_path)
182
203
 
183
204
  with open(config_path) as f:
184
205
  data: dict[str, Any] = json.load(f)
@@ -216,15 +237,13 @@ class Config:
216
237
  export_interval_ms=export_interval_ms,
217
238
  )
218
239
 
219
- return cls(
220
- server=ServerConfig(**server_data), # type: ignore
221
- logging=LoggingConfig(**logging_data), # type: ignore
222
- mcp_servers=[
223
- MCPServerConfig(**server_item) # type: ignore
224
- for server_item in mcp_servers_data # type: ignore
225
- ],
226
- telemetry=telemetry_cfg,
227
- )
240
+ self.server = ServerConfig(**server_data) # type: ignore
241
+ self.logging = LoggingConfig(**logging_data) # type: ignore
242
+ self.mcp_servers = [
243
+ MCPServerConfig(**server_item) # type: ignore
244
+ for server_item in mcp_servers_data # type: ignore
245
+ ]
246
+ self.telemetry = telemetry_cfg
228
247
 
229
248
  def save(self, config_path: Path | None = None) -> None:
230
249
  """Save configuration to JSON file"""
@@ -251,26 +270,19 @@ class Config:
251
270
 
252
271
  log.info(f"Configuration saved to {config_path}")
253
272
 
254
- @classmethod
255
- def create_default(cls) -> "Config":
273
+ def create_default(self) -> None:
256
274
  """Create default configuration"""
257
- return cls(
258
- server=ServerConfig(),
259
- logging=LoggingConfig(),
260
- mcp_servers=[
261
- MCPServerConfig(
262
- name="filesystem",
263
- command="uvx",
264
- args=["mcp-server-filesystem", "/tmp"],
265
- enabled=False,
266
- )
267
- ],
268
- telemetry=TelemetryConfig(
269
- enabled=True,
270
- otlp_endpoint=DEFAULT_OTLP_METRICS_ENDPOINT,
271
- ),
275
+ self.server = ServerConfig()
276
+ self.logging = LoggingConfig()
277
+ self.mcp_servers = [
278
+ MCPServerConfig(
279
+ name="filesystem",
280
+ command="uvx",
281
+ args=["mcp-server-filesystem", "/tmp"],
282
+ enabled=False,
283
+ )
284
+ ]
285
+ self.telemetry = TelemetryConfig(
286
+ enabled=True,
287
+ otlp_endpoint=DEFAULT_OTLP_METRICS_ENDPOINT,
272
288
  )
273
-
274
-
275
- # Load global configuration
276
- config = Config.load()
src/events.py ADDED
@@ -0,0 +1,153 @@
1
+ """
2
+ Lightweight in-process event broadcasting for Open Edison (SSE-friendly).
3
+
4
+ Provides a simple publisher/subscriber model to stream JSON events to
5
+ connected dashboard clients over Server-Sent Events (SSE).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import json
12
+ from collections.abc import AsyncIterator, Callable
13
+ from functools import wraps
14
+ from typing import Any
15
+
16
+ from loguru import logger as log
17
+
18
+ _subscribers: set[asyncio.Queue[str]] = set()
19
+ _lock = asyncio.Lock()
20
+
21
+ # One-time approvals for (session_id, kind, name)
22
+ _approvals: dict[str, asyncio.Event] = {}
23
+ _approvals_lock = asyncio.Lock()
24
+
25
+
26
+ def _approval_key(session_id: str, kind: str, name: str) -> str:
27
+ return f"{session_id}::{kind}::{name}"
28
+
29
+
30
+ def requires_loop(func: Callable[..., Any]) -> Callable[..., None | Any]: # noqa: ANN401
31
+ """Decorator to ensure the function is called when there is an asyncio event loop.
32
+ This is for sync(!) functions that return None / can do so on error"""
33
+
34
+ @wraps(func)
35
+ def wrapper(*args: Any, **kwargs: Any) -> None | Any:
36
+ if asyncio.get_event_loop_policy()._local._loop is None: # type: ignore[attr-defined]
37
+ log.warning("fire_and_forget called in non-async context")
38
+ return None
39
+ return func(*args, **kwargs)
40
+
41
+ return wrapper
42
+
43
+
44
+ async def subscribe() -> asyncio.Queue[str]:
45
+ """Register a new subscriber and return its queue of SSE strings."""
46
+ queue: asyncio.Queue[str] = asyncio.Queue(maxsize=100)
47
+ async with _lock:
48
+ _subscribers.add(queue)
49
+ log.debug(f"SSE subscriber added (total={len(_subscribers)})")
50
+ return queue
51
+
52
+
53
+ async def unsubscribe(queue: asyncio.Queue[str]) -> None:
54
+ """Remove a subscriber and drain its queue."""
55
+ async with _lock:
56
+ _subscribers.discard(queue)
57
+ log.debug(f"SSE subscriber removed (total={len(_subscribers)})")
58
+ try:
59
+ while not queue.empty():
60
+ _ = queue.get_nowait()
61
+ except Exception:
62
+ pass
63
+
64
+
65
+ async def publish(event: dict[str, Any]) -> None:
66
+ """Publish a JSON event to all subscribers.
67
+
68
+ The event is serialized and wrapped as an SSE data frame.
69
+ """
70
+ try:
71
+ data = json.dumps(event, ensure_ascii=False)
72
+ except Exception as e: # noqa: BLE001
73
+ log.error(f"Failed to serialize event for SSE: {e}")
74
+ return
75
+
76
+ frame = f"data: {data}\n\n"
77
+ async with _lock:
78
+ dead: list[asyncio.Queue[str]] = []
79
+ for q in _subscribers:
80
+ try:
81
+ # Best-effort non-blocking put; drop if full to avoid backpressure
82
+ if q.full():
83
+ _ = q.get_nowait()
84
+ q.put_nowait(frame)
85
+ except Exception:
86
+ dead.append(q)
87
+ for q in dead:
88
+ _subscribers.discard(q)
89
+
90
+
91
+ @requires_loop
92
+ def fire_and_forget(event: dict[str, Any]) -> None:
93
+ """Schedule publish(event) and log any exception when the task completes."""
94
+ task = asyncio.create_task(publish(event))
95
+
96
+ def _log_exc(t: asyncio.Task[None]) -> None:
97
+ try:
98
+ _ = t.exception()
99
+ if _ is not None:
100
+ log.error(f"SSE publish failed: {_}")
101
+ except Exception as e: # noqa: BLE001
102
+ log.error(f"SSE publish done-callback error: {e}")
103
+
104
+ task.add_done_callback(_log_exc)
105
+
106
+
107
+ async def approve_once(session_id: str, kind: str, name: str) -> None:
108
+ """Approve a single pending operation for this session/kind/name.
109
+
110
+ This unblocks exactly one waiter if present (and future waiters will create a new Event).
111
+ """
112
+ key = _approval_key(session_id, kind, name)
113
+ async with _approvals_lock:
114
+ ev = _approvals.get(key)
115
+ if ev is None:
116
+ ev = asyncio.Event()
117
+ _approvals[key] = ev
118
+ ev.set()
119
+
120
+
121
+ async def wait_for_approval(session_id: str, kind: str, name: str, timeout_s: float = 30.0) -> bool:
122
+ """Wait up to timeout for approval. Consumes the approval if granted."""
123
+ key = _approval_key(session_id, kind, name)
124
+ async with _approvals_lock:
125
+ ev = _approvals.get(key)
126
+ if ev is None:
127
+ ev = asyncio.Event()
128
+ _approvals[key] = ev
129
+ try:
130
+ await asyncio.wait_for(ev.wait(), timeout=timeout_s)
131
+ return True
132
+ except TimeoutError:
133
+ return False
134
+ finally:
135
+ # Consume the event so it does not auto-approve future waits
136
+ async with _approvals_lock:
137
+ _approvals.pop(key, None)
138
+
139
+
140
+ async def sse_stream(queue: asyncio.Queue[str]) -> AsyncIterator[bytes]:
141
+ """Yield SSE frames from the given queue with periodic heartbeats."""
142
+ try:
143
+ # Initial comment to open the stream
144
+ yield b": connected\n\n"
145
+ while True:
146
+ try:
147
+ frame = await asyncio.wait_for(queue.get(), timeout=15.0)
148
+ yield frame.encode("utf-8")
149
+ except TimeoutError:
150
+ # Heartbeat to keep the connection alive
151
+ yield b": ping\n\n"
152
+ finally:
153
+ await unsubscribe(queue)