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.
- {open_edison-0.1.19.dist-info → open_edison-0.1.26.dist-info}/METADATA +60 -41
- open_edison-0.1.26.dist-info/RECORD +17 -0
- src/cli.py +2 -1
- src/config.py +63 -51
- src/events.py +153 -0
- src/middleware/data_access_tracker.py +164 -434
- src/middleware/session_tracking.py +93 -29
- src/oauth_manager.py +281 -0
- src/permissions.py +292 -0
- src/server.py +484 -132
- src/single_user_mcp.py +221 -159
- src/telemetry.py +4 -40
- open_edison-0.1.19.dist-info/RECORD +0 -14
- {open_edison-0.1.19.dist-info → open_edison-0.1.26.dist-info}/WHEEL +0 -0
- {open_edison-0.1.19.dist-info → open_edison-0.1.26.dist-info}/entry_points.txt +0 -0
- {open_edison-0.1.19.dist-info → open_edison-0.1.26.dist-info}/licenses/LICENSE +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: open-edison
|
3
|
-
Version: 0.1.
|
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
|
-
|
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>📧
|
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
|
-
|
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
|
-
- **
|
39
|
-
- **
|
40
|
-
- **
|
41
|
-
- **
|
42
|
-
- **
|
43
|
-
- **
|
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
|
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/
|
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/
|
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/
|
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
|
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
|
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
|
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
|
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="
|
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="
|
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
|
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
|
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
|
-
|
334
|
-
|
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
|
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
|
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
|
-
#
|
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
|
-
|
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
|
-
|
180
|
-
|
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
|
-
|
220
|
-
|
221
|
-
|
222
|
-
|
223
|
-
|
224
|
-
|
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
|
-
|
255
|
-
def create_default(cls) -> "Config":
|
273
|
+
def create_default(self) -> None:
|
256
274
|
"""Create default configuration"""
|
257
|
-
|
258
|
-
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
265
|
-
|
266
|
-
|
267
|
-
|
268
|
-
|
269
|
-
|
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)
|