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.
Files changed (43) hide show
  1. main.py +112 -0
  2. mcpower_proxy-0.0.58.dist-info/METADATA +250 -0
  3. mcpower_proxy-0.0.58.dist-info/RECORD +43 -0
  4. mcpower_proxy-0.0.58.dist-info/WHEEL +5 -0
  5. mcpower_proxy-0.0.58.dist-info/entry_points.txt +2 -0
  6. mcpower_proxy-0.0.58.dist-info/licenses/LICENSE +201 -0
  7. mcpower_proxy-0.0.58.dist-info/top_level.txt +3 -0
  8. modules/__init__.py +1 -0
  9. modules/apis/__init__.py +1 -0
  10. modules/apis/security_policy.py +322 -0
  11. modules/logs/__init__.py +1 -0
  12. modules/logs/audit_trail.py +162 -0
  13. modules/logs/logger.py +128 -0
  14. modules/redaction/__init__.py +13 -0
  15. modules/redaction/constants.py +38 -0
  16. modules/redaction/gitleaks_rules.py +1268 -0
  17. modules/redaction/pii_rules.py +271 -0
  18. modules/redaction/redactor.py +599 -0
  19. modules/ui/__init__.py +1 -0
  20. modules/ui/classes.py +48 -0
  21. modules/ui/confirmation.py +200 -0
  22. modules/ui/simple_dialog.py +104 -0
  23. modules/ui/xdialog/__init__.py +249 -0
  24. modules/ui/xdialog/constants.py +13 -0
  25. modules/ui/xdialog/mac_dialogs.py +190 -0
  26. modules/ui/xdialog/tk_dialogs.py +78 -0
  27. modules/ui/xdialog/windows_custom_dialog.py +426 -0
  28. modules/ui/xdialog/windows_dialogs.py +250 -0
  29. modules/ui/xdialog/windows_structs.py +183 -0
  30. modules/ui/xdialog/yad_dialogs.py +236 -0
  31. modules/ui/xdialog/zenity_dialogs.py +156 -0
  32. modules/utils/__init__.py +1 -0
  33. modules/utils/cli.py +46 -0
  34. modules/utils/config.py +193 -0
  35. modules/utils/copy.py +36 -0
  36. modules/utils/ids.py +160 -0
  37. modules/utils/json.py +120 -0
  38. modules/utils/mcp_configs.py +48 -0
  39. wrapper/__init__.py +1 -0
  40. wrapper/__version__.py +6 -0
  41. wrapper/middleware.py +750 -0
  42. wrapper/schema.py +227 -0
  43. 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("<", "&lt;")\
17
+ .replace(">", "&gt;")\
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()
@@ -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")