open-edison 0.1.17__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.17.dist-info → open_edison-0.1.26.dist-info}/METADATA +124 -51
- 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 +165 -406
- src/middleware/session_tracking.py +93 -29
- src/oauth_manager.py +281 -0
- src/permissions.py +292 -0
- src/server.py +525 -98
- src/single_user_mcp.py +215 -153
- src/telemetry.py +4 -40
- open_edison-0.1.17.dist-info/RECORD +0 -14
- {open_edison-0.1.17.dist-info → open_edison-0.1.26.dist-info}/WHEEL +0 -0
- {open_edison-0.1.17.dist-info → open_edison-0.1.26.dist-info}/entry_points.txt +0 -0
- {open_edison-0.1.17.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,11 +25,42 @@ 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
31
|
|
32
|
-
|
32
|
+
<p align="center">
|
33
|
+
<img src="media/trifecta520p.gif" alt="Trifecta Security Risk Animation" width="520">
|
34
|
+
</p>
|
35
|
+
|
36
|
+
<div align="center">
|
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>
|
38
|
+
</div>
|
39
|
+
|
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 ✨
|
52
|
+
|
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
|
60
|
+
|
61
|
+
## Quick Start 🚀
|
62
|
+
|
63
|
+
The fastest way to get started:
|
33
64
|
|
34
65
|
```bash
|
35
66
|
# Installs uv (via Astral installer) and launches open-edison with uvx.
|
@@ -39,36 +70,31 @@ curl -fsSL https://raw.githubusercontent.com/Edison-Watch/open-edison/main/curl_
|
|
39
70
|
|
40
71
|
Run locally with uvx: `uvx open-edison --config-dir ~/edison-config`
|
41
72
|
|
73
|
+
<details>
|
74
|
+
<summary>⬇️ Install Node.js/npm (optional for MCP tools)</summary>
|
75
|
+
|
42
76
|
If you need `npx` (for Node-based MCP tools like `mcp-remote`), install Node.js as well:
|
43
77
|
|
44
|
-
-
|
45
|
-
- uv: `curl -fsSL https://astral.sh/uv/install.sh | sh`
|
46
|
-
- Node/npx: `brew install node`
|
47
|
-
- Linux (Debian/Ubuntu):
|
48
|
-
- uv: `curl -fsSL https://astral.sh/uv/install.sh | sh`
|
49
|
-
- Node/npx: `sudo apt-get update && sudo apt-get install -y nodejs npm`
|
50
|
-
- Windows (PowerShell):
|
51
|
-
- uv: `powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"`
|
52
|
-
- Node/npx: `winget install -e --id OpenJS.NodeJS`
|
78
|
+

|
53
79
|
|
54
|
-
|
80
|
+
- uv: `curl -fsSL https://astral.sh/uv/install.sh | sh`
|
81
|
+
- Node/npx: `brew install node`
|
55
82
|
|
56
|
-
|
57
|
-
|
58
|
-
|
83
|
+

|
84
|
+
|
85
|
+
- uv: `curl -fsSL https://astral.sh/uv/install.sh | sh`
|
86
|
+
- Node/npx: `sudo apt-get update && sudo apt-get install -y nodejs npm`
|
59
87
|
|
60
|
-
|
88
|
+

|
61
89
|
|
62
|
-
-
|
63
|
-
-
|
64
|
-
- **Simple local frontend** - Track and monitor your MCP interactions, servers, and sessions.
|
65
|
-
- **Session tracking** - Track and monitor your MCP interactions
|
66
|
-
- **Simple API** - REST API for managing MCP servers and proxying requests
|
67
|
-
- **Docker support** - Run in a container for easy deployment
|
90
|
+
- uv: `powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"`
|
91
|
+
- Node/npx: `winget install -e --id OpenJS.NodeJS`
|
68
92
|
|
69
|
-
|
93
|
+
After installation, ensure that `npx` is available on PATH.
|
94
|
+
</details>
|
70
95
|
|
71
|
-
|
96
|
+
<details>
|
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>
|
72
98
|
|
73
99
|
#### Prerequisites
|
74
100
|
|
@@ -91,31 +117,37 @@ open-edison run --config-dir ~/edison-config
|
|
91
117
|
OPEN_EDISON_CONFIG_DIR=~/edison-config open-edison run
|
92
118
|
```
|
93
119
|
|
94
|
-
|
120
|
+
</details>
|
121
|
+
|
122
|
+
<details>
|
123
|
+
<summary><img src="https://img.shields.io/badge/Docker-2CA5E0?style=for-the-badge&logo=docker&logoColor=white" alt="Docker"> Run with Docker</summary>
|
95
124
|
|
96
125
|
There is a dockerfile for simple local setup.
|
97
126
|
|
98
127
|
```bash
|
99
128
|
# Single-line:
|
100
|
-
git clone https://github.com/
|
129
|
+
git clone https://github.com/Edison-Watch/open-edison.git && cd open-edison && make docker_run
|
101
130
|
|
102
131
|
# Or
|
103
132
|
# Clone repo
|
104
|
-
git clone https://github.com/
|
133
|
+
git clone https://github.com/Edison-Watch/open-edison.git
|
105
134
|
# Enter repo
|
106
135
|
cd open-edison
|
107
136
|
# Build and run
|
108
137
|
make docker_run
|
109
138
|
```
|
110
139
|
|
111
|
-
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`. 🌐
|
112
141
|
|
113
|
-
|
142
|
+
</details>
|
143
|
+
|
144
|
+
<details>
|
145
|
+
<summary>⚙️ Run from source</summary>
|
114
146
|
|
115
147
|
1. Clone the repository:
|
116
148
|
|
117
149
|
```bash
|
118
|
-
git clone https://github.com/
|
150
|
+
git clone https://github.com/Edison-Watch/open-edison.git
|
119
151
|
cd open-edison
|
120
152
|
```
|
121
153
|
|
@@ -146,9 +178,12 @@ make run
|
|
146
178
|
open-edison run
|
147
179
|
```
|
148
180
|
|
149
|
-
The server will be available at `http://localhost:3000`.
|
181
|
+
The server will be available at `http://localhost:3000`. 🌐
|
182
|
+
|
183
|
+
</details>
|
150
184
|
|
151
|
-
|
185
|
+
<details>
|
186
|
+
<summary>🔌 MCP Connection</summary>
|
152
187
|
|
153
188
|
Connect any MCP client to Open Edison (requires Node.js/npm for `npx`):
|
154
189
|
|
@@ -169,19 +204,23 @@ Or add to your MCP client config:
|
|
169
204
|
}
|
170
205
|
```
|
171
206
|
|
172
|
-
|
207
|
+
</details>
|
208
|
+
|
209
|
+
<details>
|
210
|
+
<summary>🧭 Usage</summary>
|
173
211
|
|
174
212
|
### API Endpoints
|
175
213
|
|
176
214
|
See [API Reference](docs/quick-reference/api_reference.md) for full API documentation.
|
177
215
|
|
178
|
-
|
216
|
+
<details>
|
217
|
+
<summary>🛠️ Development</summary>
|
179
218
|
|
180
|
-
### Setup
|
219
|
+
### Setup 🧰
|
181
220
|
|
182
221
|
Setup from source as above.
|
183
222
|
|
184
|
-
### Run
|
223
|
+
### Run ▶️
|
185
224
|
|
186
225
|
Server doesn't have any auto-reload at the moment, so you'll need to run & ctrl-c this during development.
|
187
226
|
|
@@ -189,7 +228,7 @@ Server doesn't have any auto-reload at the moment, so you'll need to run & ctrl-
|
|
189
228
|
make run
|
190
229
|
```
|
191
230
|
|
192
|
-
### Tests/code quality
|
231
|
+
### Tests/code quality ✅
|
193
232
|
|
194
233
|
We expect `make ci` to return cleanly.
|
195
234
|
|
@@ -197,7 +236,12 @@ We expect `make ci` to return cleanly.
|
|
197
236
|
make ci
|
198
237
|
```
|
199
238
|
|
200
|
-
|
239
|
+
</details>
|
240
|
+
|
241
|
+
<details>
|
242
|
+
<summary>⚙️ Configuration (config.json)</summary>
|
243
|
+
|
244
|
+
## Configuration ⚙️
|
201
245
|
|
202
246
|
The `config.json` file contains all configuration:
|
203
247
|
|
@@ -215,21 +259,32 @@ Each MCP server configuration includes:
|
|
215
259
|
- `env` - Environment variables (optional)
|
216
260
|
- `enabled` - Whether to auto-start this server
|
217
261
|
|
218
|
-
|
262
|
+
</details>
|
219
263
|
|
220
|
-
|
264
|
+
</details>
|
265
|
+
|
266
|
+
## 🔐 How Edison prevents data leakages
|
267
|
+
|
268
|
+
<details>
|
269
|
+
<summary>🔱 The lethal trifecta, agent lifecycle management</summary>
|
270
|
+
|
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/):
|
272
|
+
|
273
|
+
<img src="media/lethal-trifecta.png" alt="The lethal trifecta diagram showing the three key AI agent security risks" width="70%">
|
221
274
|
|
222
275
|
1. **Private data access** - Access to sensitive local files/data
|
223
276
|
2. **Untrusted content exposure** - Exposure to external/web content
|
224
277
|
3. **External communication** - Ability to write/send data externally
|
225
278
|
|
279
|
+
<img src="media/pam-diagram.png" alt="Privileged Access Management (PAM) example showing the lethal trifecta in action" width="90%">
|
280
|
+
|
226
281
|
The configuration allows you to classify these risks across **tools**, **resources**, and **prompts** using separate configuration files.
|
227
282
|
|
228
283
|
In addition to trifecta, we track Access Control Level (ACL) for each tool call,
|
229
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.
|
230
285
|
If a write operation is attempted to a lower ACL level, it is blocked.
|
231
286
|
|
232
|
-
### Tool Permissions (`tool_permissions.json`)
|
287
|
+
### 🧰 Tool Permissions (`tool_permissions.json`)
|
233
288
|
|
234
289
|
Defines security classifications for MCP tools. See full file: [tool_permissions.json](tool_permissions.json), it looks like:
|
235
290
|
|
@@ -246,6 +301,9 @@ Defines security classifications for MCP tools. See full file: [tool_permissions
|
|
246
301
|
}
|
247
302
|
```
|
248
303
|
|
304
|
+
<details>
|
305
|
+
<summary>📁 Resource Permissions (`resource_permissions.json`)</summary>
|
306
|
+
|
249
307
|
### Resource Permissions (`resource_permissions.json`)
|
250
308
|
|
251
309
|
Defines security classifications for resource access patterns. See full file: [resource_permissions.json](resource_permissions.json), it looks like:
|
@@ -257,6 +315,11 @@ Defines security classifications for resource access patterns. See full file: [r
|
|
257
315
|
}
|
258
316
|
```
|
259
317
|
|
318
|
+
</details>
|
319
|
+
|
320
|
+
<details>
|
321
|
+
<summary>💬 Prompt Permissions (`prompt_permissions.json`)</summary>
|
322
|
+
|
260
323
|
### Prompt Permissions (`prompt_permissions.json`)
|
261
324
|
|
262
325
|
Defines security classifications for prompt types. See full file: [prompt_permissions.json](prompt_permissions.json), it looks like:
|
@@ -268,7 +331,9 @@ Defines security classifications for prompt types. See full file: [prompt_permis
|
|
268
331
|
}
|
269
332
|
```
|
270
333
|
|
271
|
-
|
334
|
+
</details>
|
335
|
+
|
336
|
+
### Wildcard Patterns ✨
|
272
337
|
|
273
338
|
All permission types support wildcard patterns:
|
274
339
|
|
@@ -276,21 +341,29 @@ All permission types support wildcard patterns:
|
|
276
341
|
- **Resources**: `scheme:*` (e.g., `file:*` matches all file resources)
|
277
342
|
- **Prompts**: `type:*` (e.g., `template:*` matches all template prompts)
|
278
343
|
|
279
|
-
### Security Monitoring
|
344
|
+
### Security Monitoring 🕵️
|
280
345
|
|
281
346
|
**All items must be explicitly configured** - unknown tools/resources/prompts will be rejected for security.
|
282
347
|
|
283
348
|
Use the `get_security_status` tool to monitor your session's current risk level and see which capabilities have been accessed. When the lethal trifecta is achieved (all three risk flags set), further potentially dangerous operations are blocked.
|
284
349
|
|
285
|
-
|
350
|
+
</details>
|
351
|
+
|
352
|
+
|
353
|
+
|
354
|
+
## Documentation 📚
|
286
355
|
|
287
356
|
📚 **Complete documentation available in [`docs/`](docs/)**
|
288
357
|
|
289
|
-
- **[Getting Started](docs/quick-reference/config_quick_start.md)** - Quick setup guide
|
290
|
-
- **[Configuration](docs/core/configuration.md)** - Complete configuration reference
|
291
|
-
- **[API Reference](docs/quick-reference/api_reference.md)** - REST API documentation
|
292
|
-
- **[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
|
293
362
|
|
294
|
-
|
363
|
+
|
364
|
+
<details>
|
365
|
+
<summary>📄 License</summary>
|
295
366
|
|
296
367
|
GPL-3.0 License - see [LICENSE](LICENSE) for details.
|
368
|
+
|
369
|
+
</details>
|
@@ -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)
|