mcpower-proxy 0.0.58__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.
- main.py +112 -0
- mcpower_proxy-0.0.58.dist-info/METADATA +250 -0
- mcpower_proxy-0.0.58.dist-info/RECORD +43 -0
- mcpower_proxy-0.0.58.dist-info/WHEEL +5 -0
- mcpower_proxy-0.0.58.dist-info/entry_points.txt +2 -0
- mcpower_proxy-0.0.58.dist-info/licenses/LICENSE +201 -0
- mcpower_proxy-0.0.58.dist-info/top_level.txt +3 -0
- modules/__init__.py +1 -0
- modules/apis/__init__.py +1 -0
- modules/apis/security_policy.py +322 -0
- modules/logs/__init__.py +1 -0
- modules/logs/audit_trail.py +162 -0
- modules/logs/logger.py +128 -0
- modules/redaction/__init__.py +13 -0
- modules/redaction/constants.py +38 -0
- modules/redaction/gitleaks_rules.py +1268 -0
- modules/redaction/pii_rules.py +271 -0
- modules/redaction/redactor.py +599 -0
- modules/ui/__init__.py +1 -0
- modules/ui/classes.py +48 -0
- modules/ui/confirmation.py +200 -0
- modules/ui/simple_dialog.py +104 -0
- modules/ui/xdialog/__init__.py +249 -0
- modules/ui/xdialog/constants.py +13 -0
- modules/ui/xdialog/mac_dialogs.py +190 -0
- modules/ui/xdialog/tk_dialogs.py +78 -0
- modules/ui/xdialog/windows_custom_dialog.py +426 -0
- modules/ui/xdialog/windows_dialogs.py +250 -0
- modules/ui/xdialog/windows_structs.py +183 -0
- modules/ui/xdialog/yad_dialogs.py +236 -0
- modules/ui/xdialog/zenity_dialogs.py +156 -0
- modules/utils/__init__.py +1 -0
- modules/utils/cli.py +46 -0
- modules/utils/config.py +193 -0
- modules/utils/copy.py +36 -0
- modules/utils/ids.py +160 -0
- modules/utils/json.py +120 -0
- modules/utils/mcp_configs.py +48 -0
- wrapper/__init__.py +1 -0
- wrapper/__version__.py +6 -0
- wrapper/middleware.py +750 -0
- wrapper/schema.py +227 -0
- wrapper/server.py +78 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
from os.path import isfile
|
|
3
|
+
from typing import Tuple
|
|
4
|
+
|
|
5
|
+
from .constants import *
|
|
6
|
+
|
|
7
|
+
def clean(txt: str):
|
|
8
|
+
return txt\
|
|
9
|
+
.replace("\\", "\\\\")\
|
|
10
|
+
.replace("$", "\\$")\
|
|
11
|
+
.replace("!", "\\!")\
|
|
12
|
+
.replace("*", "\\*")\
|
|
13
|
+
.replace("?", "\\?")\
|
|
14
|
+
.replace("&", "&")\
|
|
15
|
+
.replace("|", "|")\
|
|
16
|
+
.replace("<", "<")\
|
|
17
|
+
.replace(">", ">")\
|
|
18
|
+
.replace("(", "\\(")\
|
|
19
|
+
.replace(")", "\\)")\
|
|
20
|
+
.replace("[", "\\[")\
|
|
21
|
+
.replace("]", "\\]")\
|
|
22
|
+
.replace("{", "\\{")\
|
|
23
|
+
.replace("}", "\\}")\
|
|
24
|
+
|
|
25
|
+
def zenity(typ, filetypes=None, **kwargs) -> Tuple[int, str]:
|
|
26
|
+
# Build args based on keywords
|
|
27
|
+
args = ['zenity', '--'+typ]
|
|
28
|
+
for k, v in kwargs.items():
|
|
29
|
+
if v is True:
|
|
30
|
+
args.append(f'--{k.replace("_", "-").strip("-")}')
|
|
31
|
+
elif isinstance(v, str):
|
|
32
|
+
cv = clean(v) if k != "title" else v
|
|
33
|
+
args.append(f'--{k.replace("_", "-").strip("-")}={cv}')
|
|
34
|
+
|
|
35
|
+
# Build filetypes specially if specified
|
|
36
|
+
if filetypes:
|
|
37
|
+
for name, globs in filetypes:
|
|
38
|
+
if name:
|
|
39
|
+
globlist = globs.split()
|
|
40
|
+
args.append(f'--file-filter={name.replace("|", "")} ({", ".join(t for t in globlist)})|{globs}')
|
|
41
|
+
|
|
42
|
+
proc = subprocess.Popen(
|
|
43
|
+
args,
|
|
44
|
+
stdout=subprocess.PIPE,
|
|
45
|
+
stderr=subprocess.DEVNULL,
|
|
46
|
+
shell=False
|
|
47
|
+
)
|
|
48
|
+
stdout, _ = proc.communicate()
|
|
49
|
+
|
|
50
|
+
return (proc.returncode, stdout.decode('utf-8').strip())
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def open_file(title, filetypes, multiple=False):
|
|
54
|
+
# Zenity is strange and will let you select folders for some reason in some cases. So we filter those out.
|
|
55
|
+
if multiple:
|
|
56
|
+
files = zenity('file-selection', title=title, filetypes=filetypes, multiple=True, separator="\n")[1].splitlines()
|
|
57
|
+
return list(filter(isfile, files))
|
|
58
|
+
else:
|
|
59
|
+
file = zenity('file-selection', title=title, filetypes=filetypes)[1]
|
|
60
|
+
if file and isfile(file):
|
|
61
|
+
return file
|
|
62
|
+
else:
|
|
63
|
+
return ''
|
|
64
|
+
|
|
65
|
+
def save_file(title, filetypes):
|
|
66
|
+
return zenity('file-selection', title=title, filetypes=filetypes, save=True)[1]
|
|
67
|
+
|
|
68
|
+
def directory(title):
|
|
69
|
+
return zenity("file-selection", title=title, directory=True)[1]
|
|
70
|
+
|
|
71
|
+
def info(title, message):
|
|
72
|
+
zenity("info", title=title, text=message)
|
|
73
|
+
|
|
74
|
+
def warning(title, message):
|
|
75
|
+
zenity("warning", title=title, text=message)
|
|
76
|
+
|
|
77
|
+
def error(title, message):
|
|
78
|
+
zenity("error", title=title, text=message)
|
|
79
|
+
|
|
80
|
+
def yesno(title, message):
|
|
81
|
+
return zenity("question", title=title, text=message)[0]
|
|
82
|
+
|
|
83
|
+
def yesno_always(title, message, yes_always=False, no_always=False):
|
|
84
|
+
"""
|
|
85
|
+
Enhanced yes/no dialog with optional always buttons
|
|
86
|
+
Button order: No, No Always (if enabled), Yes, Yes Always (if enabled)
|
|
87
|
+
|
|
88
|
+
Note: Zenity has limited custom button support, so we fall back to yesno
|
|
89
|
+
for now. Full implementation would require GTK+ dialog creation.
|
|
90
|
+
"""
|
|
91
|
+
# TODO: Implement custom Zenity dialog with proper button layout
|
|
92
|
+
# For now, fall back to standard yesno dialog
|
|
93
|
+
return yesno(title, message)
|
|
94
|
+
|
|
95
|
+
def yesnocancel(title, message):
|
|
96
|
+
r = zenity(
|
|
97
|
+
"question",
|
|
98
|
+
title=title,
|
|
99
|
+
text=message,
|
|
100
|
+
extra_button="No",
|
|
101
|
+
cancel_label="Cancel"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
if r[1]:
|
|
105
|
+
return NO
|
|
106
|
+
elif r[0]:
|
|
107
|
+
return CANCEL
|
|
108
|
+
else:
|
|
109
|
+
return YES
|
|
110
|
+
|
|
111
|
+
def retrycancel(title, message):
|
|
112
|
+
r = zenity(
|
|
113
|
+
"question",
|
|
114
|
+
title=(title or ""),
|
|
115
|
+
text=message,
|
|
116
|
+
ok_label="Retry",
|
|
117
|
+
cancel_label="Cancel"
|
|
118
|
+
)[0]
|
|
119
|
+
|
|
120
|
+
if r:
|
|
121
|
+
return CANCEL
|
|
122
|
+
else:
|
|
123
|
+
return RETRY
|
|
124
|
+
|
|
125
|
+
def okcancel(title, message):
|
|
126
|
+
r = zenity(
|
|
127
|
+
"question",
|
|
128
|
+
title=title,
|
|
129
|
+
text=message,
|
|
130
|
+
ok_label="Ok",
|
|
131
|
+
cancel_label="Cancel"
|
|
132
|
+
)[0]
|
|
133
|
+
|
|
134
|
+
if r:
|
|
135
|
+
return CANCEL
|
|
136
|
+
else:
|
|
137
|
+
return OK
|
|
138
|
+
|
|
139
|
+
def generic_dialog(title, message, buttons, default_button, icon):
|
|
140
|
+
"""
|
|
141
|
+
Generic dialog with custom buttons and icon
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
title (str): Dialog title
|
|
145
|
+
message (str): Dialog message
|
|
146
|
+
buttons (list): List of button text strings
|
|
147
|
+
default_button (int): Index of default button (0-based)
|
|
148
|
+
icon (str): Icon type (ICON_QUESTION, ICON_WARNING, ICON_ERROR, ICON_INFO)
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
int: Index of clicked button (0-based)
|
|
152
|
+
|
|
153
|
+
Raises:
|
|
154
|
+
NotImplementedError: Zenity has limited custom button support
|
|
155
|
+
"""
|
|
156
|
+
raise NotImplementedError("generic_dialog is not supported on Zenity - custom buttons are not fully supported")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Utilities package
|
modules/utils/cli.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI utilities for MCPower Proxy
|
|
3
|
+
"""
|
|
4
|
+
import argparse
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def parse_args():
|
|
8
|
+
"""Parse command line arguments"""
|
|
9
|
+
parser = argparse.ArgumentParser(
|
|
10
|
+
description="MCPower - Transparent 1:1 MCP Wrapper with security enforcement",
|
|
11
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
12
|
+
epilog="""
|
|
13
|
+
Examples:
|
|
14
|
+
# Single server config
|
|
15
|
+
%(prog)s --wrapped-config '{"command": "npx", "args": ["@modelcontextprotocol/server-filesystem", "/path/to/allowed-dir"]}'
|
|
16
|
+
|
|
17
|
+
# Named server config
|
|
18
|
+
%(prog)s --wrapped-config '{"my-server": {"command": "python", "args": ["server.py"], "env": {"DEBUG": "1"}}}'
|
|
19
|
+
|
|
20
|
+
# MCPConfig format
|
|
21
|
+
%(prog)s --wrapped-config '{"mcpServers": {"default": {"command": "node", "args": ["server.js"]}}}'
|
|
22
|
+
|
|
23
|
+
# With custom name
|
|
24
|
+
%(prog)s --wrapped-config '{"command": "node", "args": ["server.js"]}' --name MyWrapper
|
|
25
|
+
|
|
26
|
+
Reference Links:
|
|
27
|
+
• FastMCP Proxy: https://gofastmcp.com/servers/proxy
|
|
28
|
+
• FastMCP Middleware: https://gofastmcp.com/servers/middleware
|
|
29
|
+
• MCP Official: https://modelcontextprotocol.io
|
|
30
|
+
• Claude MCP Config: https://docs.anthropic.com/en/docs/claude-code/mcp
|
|
31
|
+
"""
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
'--wrapped-config',
|
|
36
|
+
required=True,
|
|
37
|
+
help='JSON/JSONC configuration for the wrapped MCP server (FastMCP will handle validation)'
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
'--name',
|
|
42
|
+
default='MCPWrapper',
|
|
43
|
+
help='Name for the wrapper MCP server (default: MCPWrapper)'
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
return parser.parse_args()
|
modules/utils/config.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Centralized configuration loader with caching
|
|
3
|
+
Reads from ~/.mcpower/config file
|
|
4
|
+
"""
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Dict, Optional
|
|
7
|
+
|
|
8
|
+
from modules.logs.logger import MCPLogger
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def load_default_config() -> Dict[str, str]:
|
|
12
|
+
"""Load default configuration from embedded constants"""
|
|
13
|
+
try:
|
|
14
|
+
config_data = {
|
|
15
|
+
"API_URL": "https://api.mcpower.tech",
|
|
16
|
+
"DEBUG": "0"
|
|
17
|
+
}
|
|
18
|
+
return config_data
|
|
19
|
+
except Exception as e:
|
|
20
|
+
raise RuntimeError(f"FATAL: Failed to load default config: {e}")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Load default configuration from shared file
|
|
24
|
+
default_config = load_default_config()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ConfigManager:
|
|
28
|
+
"""Singleton configuration manager with caching and file monitoring"""
|
|
29
|
+
|
|
30
|
+
_instance: Optional['ConfigManager'] = None
|
|
31
|
+
_config: Optional[Dict[str, str]] = None
|
|
32
|
+
_observer = None # Will be watchdog Observer if available
|
|
33
|
+
|
|
34
|
+
def __new__(cls):
|
|
35
|
+
if cls._instance is None:
|
|
36
|
+
cls._instance = super().__new__(cls)
|
|
37
|
+
return cls._instance
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def config(self) -> Dict[str, str]:
|
|
41
|
+
"""Get cached config, loading if necessary"""
|
|
42
|
+
if self._config is None:
|
|
43
|
+
self._config = ConfigManager._load_config()
|
|
44
|
+
return self._config
|
|
45
|
+
|
|
46
|
+
@staticmethod
|
|
47
|
+
def _load_config() -> Dict[str, str]:
|
|
48
|
+
"""Load config from ~/.mcpower/config file"""
|
|
49
|
+
config_path = ConfigManager.get_config_path()
|
|
50
|
+
|
|
51
|
+
# Create default config if it doesn't exist
|
|
52
|
+
if not config_path.exists():
|
|
53
|
+
ConfigManager._create_default_config(config_path)
|
|
54
|
+
|
|
55
|
+
content = config_path.read_text()
|
|
56
|
+
# Parse key=value configuration content
|
|
57
|
+
new_config = {}
|
|
58
|
+
for line in content.split('\n'):
|
|
59
|
+
line = line.strip()
|
|
60
|
+
if line and '=' in line and not line.startswith('#'):
|
|
61
|
+
key, value = line.split('=', 1)
|
|
62
|
+
new_config[key.strip()] = value.strip()
|
|
63
|
+
return new_config
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def get_config_path(cls):
|
|
67
|
+
return ConfigManager.get_user_config_dir() / 'config'
|
|
68
|
+
|
|
69
|
+
@staticmethod
|
|
70
|
+
def _create_default_config(config_path: Path):
|
|
71
|
+
"""Create default config file"""
|
|
72
|
+
config_path.parent.mkdir(exist_ok=True)
|
|
73
|
+
|
|
74
|
+
# Convert to key=value format
|
|
75
|
+
config_lines = ['# MCPower Configuration']
|
|
76
|
+
for key, value in default_config.items():
|
|
77
|
+
config_lines.append(f'{key}={value}')
|
|
78
|
+
|
|
79
|
+
config_path.write_text('\n'.join(config_lines) + '\n')
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
def get_user_config_dir() -> Path:
|
|
83
|
+
"""Get user config directory (~/.mcpower)"""
|
|
84
|
+
return Path.home() / '.mcpower'
|
|
85
|
+
|
|
86
|
+
def get(self, key: str, default: str = None) -> str:
|
|
87
|
+
"""Get config value with optional default"""
|
|
88
|
+
return self.config.get(key, default)
|
|
89
|
+
|
|
90
|
+
def reload(self, logger: MCPLogger):
|
|
91
|
+
"""Force reload config from file"""
|
|
92
|
+
self._config = None
|
|
93
|
+
logger.debug("Config reloaded from ~/.mcpower/config")
|
|
94
|
+
|
|
95
|
+
def start_monitoring(self, logger: MCPLogger):
|
|
96
|
+
"""Start file system monitoring using watchdog (event-driven)"""
|
|
97
|
+
try:
|
|
98
|
+
# Try to import watchdog - graceful fallback if not available
|
|
99
|
+
from watchdog.observers import Observer
|
|
100
|
+
from watchdog.events import FileSystemEventHandler
|
|
101
|
+
|
|
102
|
+
if self._observer is not None:
|
|
103
|
+
return # Already monitoring
|
|
104
|
+
|
|
105
|
+
class ConfigFileHandler(FileSystemEventHandler):
|
|
106
|
+
def __init__(self, config_manager):
|
|
107
|
+
self.config_manager = config_manager
|
|
108
|
+
self.config_path = str(config_manager.get_user_config_dir() / 'config')
|
|
109
|
+
|
|
110
|
+
def on_modified(self, event):
|
|
111
|
+
if not event.is_directory and event.src_path == self.config_path:
|
|
112
|
+
logger.debug("Config file changed (event), reloading...")
|
|
113
|
+
self.config_manager.reload(logger)
|
|
114
|
+
|
|
115
|
+
def on_created(self, event):
|
|
116
|
+
if not event.is_directory and event.src_path == self.config_path:
|
|
117
|
+
logger.debug("Config file created (event), reloading...")
|
|
118
|
+
self.config_manager.reload(logger)
|
|
119
|
+
|
|
120
|
+
self._observer = Observer()
|
|
121
|
+
event_handler = ConfigFileHandler(self)
|
|
122
|
+
watch_dir = str(self.get_user_config_dir())
|
|
123
|
+
|
|
124
|
+
self._observer.schedule(event_handler, watch_dir, recursive=False)
|
|
125
|
+
self._observer.start()
|
|
126
|
+
logger.debug("Started monitoring ~/.mcpower/config (event-driven)")
|
|
127
|
+
|
|
128
|
+
except ImportError:
|
|
129
|
+
logger.warning("watchdog not available, install with: pip install watchdog")
|
|
130
|
+
logger.warning("Falling back to manual reload - call config.reload() when needed")
|
|
131
|
+
except Exception as e:
|
|
132
|
+
logger.warning(f"Failed to start file monitoring: {e}")
|
|
133
|
+
logger.warning("Falling back to manual reload - call config.reload() when needed")
|
|
134
|
+
|
|
135
|
+
def stop_monitoring(self, logger: MCPLogger):
|
|
136
|
+
"""Stop file system monitoring"""
|
|
137
|
+
if self._observer is not None:
|
|
138
|
+
self._observer.stop()
|
|
139
|
+
self._observer.join()
|
|
140
|
+
self._observer = None
|
|
141
|
+
logger.debug("Stopped monitoring ~/.mcpower/config")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# Singleton instance
|
|
145
|
+
config = ConfigManager()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# Convenience functions
|
|
149
|
+
|
|
150
|
+
def resolve_config_path(path_value: str) -> str:
|
|
151
|
+
"""
|
|
152
|
+
Resolve a configuration path value, handling ./ as relative to config folder
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
path_value: The path value from config (e.g., './log.txt', '/tmp/log.txt', 'log.txt')
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
Resolved absolute path string
|
|
159
|
+
"""
|
|
160
|
+
if path_value.startswith('./'):
|
|
161
|
+
config_dir = ConfigManager.get_user_config_dir()
|
|
162
|
+
return str(config_dir / path_value[2:]) # Remove ./ prefix
|
|
163
|
+
|
|
164
|
+
return path_value
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def get_api_url() -> str:
|
|
168
|
+
"""Get default API URL"""
|
|
169
|
+
key = 'API_URL'
|
|
170
|
+
return config.get(key, default_config.get(key))
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def get_log_path() -> str:
|
|
174
|
+
"""Get default log file path """
|
|
175
|
+
return str(ConfigManager.get_user_config_dir() / 'mcp-wrapper.log')
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def get_audit_trail_path() -> str:
|
|
179
|
+
"""Get audit trail log file path (same directory as main log)"""
|
|
180
|
+
return str(ConfigManager.get_user_config_dir() / 'audit_trail.log')
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def is_debug_mode() -> bool:
|
|
184
|
+
"""Get debug mode setting"""
|
|
185
|
+
key = 'DEBUG'
|
|
186
|
+
value = str(config.get(key, default_config.get(key)))
|
|
187
|
+
return value.lower() in ('true', '1', 'yes', 'on')
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def get_user_id(logger: MCPLogger) -> str:
|
|
191
|
+
"""Get or create user ID from ~/.mcpower/uid (never fails)"""
|
|
192
|
+
from modules.utils.ids import get_or_create_user_id
|
|
193
|
+
return get_or_create_user_id(logger)
|
modules/utils/copy.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
from typing import TypeVar, Dict, Any
|
|
3
|
+
|
|
4
|
+
T = TypeVar('T')
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def safe_copy(obj: T, update_dict: Dict[str, Any] = None) -> T:
|
|
8
|
+
"""
|
|
9
|
+
Safely copy an object (Pydantic model or regular object) with optional updates.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
obj: The object to copy (can be Pydantic model or regular object)
|
|
13
|
+
update_dict: Optional dictionary of field updates to apply
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
A copy of the object with updates applied
|
|
17
|
+
"""
|
|
18
|
+
if update_dict is None:
|
|
19
|
+
update_dict = {}
|
|
20
|
+
|
|
21
|
+
# Try Pydantic model_copy first (v2)
|
|
22
|
+
if hasattr(obj, 'model_copy'):
|
|
23
|
+
return obj.model_copy(update=update_dict)
|
|
24
|
+
|
|
25
|
+
# Try generic copy method (v1 or custom objects)
|
|
26
|
+
elif hasattr(obj, 'copy'):
|
|
27
|
+
return obj.copy(update=update_dict)
|
|
28
|
+
|
|
29
|
+
# Fallback to manual copying for regular objects
|
|
30
|
+
else:
|
|
31
|
+
# Use deepcopy to match original behavior and ensure complete isolation
|
|
32
|
+
new_obj = copy.deepcopy(obj)
|
|
33
|
+
# Apply updates
|
|
34
|
+
for key, value in update_dict.items():
|
|
35
|
+
setattr(new_obj, key, value)
|
|
36
|
+
return new_obj
|
modules/utils/ids.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Utilities for generating event IDs, session IDs, app UIDs, and timing helpers
|
|
3
|
+
"""
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
# Process-wide session ID cache
|
|
11
|
+
_session_id: Optional[str] = None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def generate_event_id() -> str:
|
|
15
|
+
"""
|
|
16
|
+
Generate unique event ID for MCP operations
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
Unique event ID string
|
|
20
|
+
"""
|
|
21
|
+
timestamp = int(time.time() * 1000) # milliseconds
|
|
22
|
+
unique_part = str(uuid.uuid4())[:8]
|
|
23
|
+
return f"{timestamp}-{unique_part}"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_session_id() -> str:
|
|
27
|
+
"""
|
|
28
|
+
Get session ID for the current process. Returns the same value for all calls
|
|
29
|
+
within the same Python process instance.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
Process-wide session ID string
|
|
33
|
+
"""
|
|
34
|
+
global _session_id
|
|
35
|
+
if _session_id is None:
|
|
36
|
+
_session_id = str(uuid.uuid4())
|
|
37
|
+
return _session_id
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def is_valid_uuid(uuid_string: str) -> bool:
|
|
41
|
+
"""
|
|
42
|
+
Validate if a string is a valid UUID
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
uuid_string: String to validate
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
True if valid UUID, False otherwise
|
|
49
|
+
"""
|
|
50
|
+
try:
|
|
51
|
+
uuid.UUID(uuid_string)
|
|
52
|
+
return True
|
|
53
|
+
except ValueError:
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _atomic_write_uuid(file_path: Path, new_uuid: str) -> bool:
|
|
58
|
+
"""
|
|
59
|
+
Attempt atomic write of UUID to file using O_CREAT | O_EXCL.
|
|
60
|
+
Returns True if write succeeded, False if file already exists.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
file_path: Path to write UUID
|
|
64
|
+
new_uuid: UUID string to write
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
True if write succeeded, False if file exists
|
|
68
|
+
"""
|
|
69
|
+
try:
|
|
70
|
+
fd = os.open(str(file_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
71
|
+
try:
|
|
72
|
+
os.write(fd, new_uuid.encode('utf-8'))
|
|
73
|
+
finally:
|
|
74
|
+
os.close(fd)
|
|
75
|
+
return True
|
|
76
|
+
except FileExistsError:
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _get_or_create_uuid(uid_path: Path, logger, id_type: str) -> str:
|
|
81
|
+
"""
|
|
82
|
+
Get or create UUID at specified path with race safety.
|
|
83
|
+
Common logic shared by user ID and app UID generation.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
uid_path: Path to UUID file
|
|
87
|
+
logger: Logger instance
|
|
88
|
+
id_type: Description for logging (e.g., "user ID", "app UID")
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
UUID string
|
|
92
|
+
"""
|
|
93
|
+
uid_path.parent.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
|
|
95
|
+
max_attempts = 3
|
|
96
|
+
for attempt in range(max_attempts):
|
|
97
|
+
if uid_path.exists():
|
|
98
|
+
try:
|
|
99
|
+
existing_uid = uid_path.read_text().strip()
|
|
100
|
+
if is_valid_uuid(existing_uid):
|
|
101
|
+
return existing_uid
|
|
102
|
+
logger.warning(f"Invalid UUID in {uid_path}, regenerating (attempt {attempt + 1}/{max_attempts})")
|
|
103
|
+
uid_path.unlink()
|
|
104
|
+
except Exception as e:
|
|
105
|
+
logger.warning(f"Failed to read {uid_path}: {e} (attempt {attempt + 1}/{max_attempts})")
|
|
106
|
+
if attempt < max_attempts - 1:
|
|
107
|
+
time.sleep(0.1 * (2 ** attempt))
|
|
108
|
+
continue
|
|
109
|
+
raise
|
|
110
|
+
|
|
111
|
+
new_uid = str(uuid.uuid4())
|
|
112
|
+
|
|
113
|
+
if _atomic_write_uuid(uid_path, new_uid):
|
|
114
|
+
logger.info(f"Generated {id_type}: {new_uid}")
|
|
115
|
+
return new_uid
|
|
116
|
+
|
|
117
|
+
logger.debug(f"{id_type.title()} file created by another process, reading (attempt {attempt + 1}/{max_attempts})")
|
|
118
|
+
if attempt < max_attempts - 1:
|
|
119
|
+
time.sleep(0.05)
|
|
120
|
+
|
|
121
|
+
raise RuntimeError(f"Failed to get or create {id_type} after {max_attempts} attempts")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def get_or_create_user_id(logger) -> str:
|
|
125
|
+
"""
|
|
126
|
+
Get or create machine-wide user ID from ~/.mcpower/uid
|
|
127
|
+
Race-safe: multiple concurrent processes will converge on single ID
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
logger: Logger instance for messages
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
User ID string
|
|
134
|
+
"""
|
|
135
|
+
uid_path = Path.home() / ".mcpower" / "uid"
|
|
136
|
+
return _get_or_create_uuid(uid_path, logger, "user ID")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def read_app_uid(logger, project_folder_path: str) -> str:
|
|
140
|
+
"""
|
|
141
|
+
Get or create app UID from project folder's .mcpower/app_uid file
|
|
142
|
+
Race-safe: multiple concurrent processes will converge on single ID
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
logger: Logger instance for messages
|
|
146
|
+
project_folder_path: Path to the project folder
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
App UID string
|
|
150
|
+
"""
|
|
151
|
+
project_path = Path(project_folder_path)
|
|
152
|
+
|
|
153
|
+
# Check if path already contains .mcpower (forced/default case)
|
|
154
|
+
if ".mcpower" in project_path.parts:
|
|
155
|
+
uid_path = project_path / "app_uid"
|
|
156
|
+
else:
|
|
157
|
+
# Project-specific case
|
|
158
|
+
uid_path = project_path / ".mcpower" / "app_uid"
|
|
159
|
+
|
|
160
|
+
return _get_or_create_uuid(uid_path, logger, "app UID")
|