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,322 @@
1
+ """Security Policy API Client"""
2
+
3
+ import json
4
+ import uuid
5
+ from typing import Dict, Any, Optional, List
6
+ import time
7
+
8
+ import httpx
9
+
10
+ from mcpower_shared.mcp_types import PolicyRequest, PolicyResponse, InitRequest, UserConfirmation, InspectDecision
11
+ from modules.logs.audit_trail import AuditTrailLogger
12
+ from modules.logs.logger import MCPLogger
13
+ from modules.redaction import redact
14
+ from modules.utils.config import get_api_url, get_user_id
15
+ from modules.utils.json import safe_json_dumps, to_dict
16
+
17
+
18
+ class SecurityAPIError(Exception):
19
+ """Security API communication error"""
20
+ pass
21
+
22
+
23
+ class RateLimitExhaustedError(SecurityAPIError):
24
+ """Security API rate limit exhausted (429) error"""
25
+ def __init__(self, message: str, retry_after: int = None):
26
+ super().__init__(message)
27
+ self.retry_after = retry_after
28
+
29
+
30
+ class SecurityPolicyClient:
31
+ """HTTP client for security policy API calls"""
32
+
33
+ # Class-level tracking for 429 notifications per session
34
+ _session_notification_times: Dict[str, float] = {}
35
+
36
+ def __init__(self, session_id: str, logger: MCPLogger, audit_logger: AuditTrailLogger, app_id: str,
37
+ timeout: float = 60.0):
38
+ self.base_url = get_api_url().rstrip('/')
39
+ self.timeout = timeout
40
+ self.client: Optional[httpx.AsyncClient] = None
41
+ self.logger = logger
42
+ self.audit_logger = audit_logger
43
+ self.user_id = get_user_id(logger)
44
+ self.app_id = app_id
45
+ self.session_id = session_id
46
+
47
+ async def __aenter__(self):
48
+ self.client = httpx.AsyncClient(timeout=self.timeout)
49
+ return self
50
+
51
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
52
+ if self.client:
53
+ await self.client.aclose()
54
+
55
+ async def inspect_policy_request(self, policy_request: PolicyRequest,
56
+ prompt_id: str) -> InspectDecision:
57
+ """Call inspect_policy_request API endpoint"""
58
+ if not self.client:
59
+ raise SecurityAPIError("Client not initialized - use async context manager")
60
+
61
+ return await self._make_request("/inspect_request", policy_request, method="POST",
62
+ redacted_keys=[
63
+ "$.tool.description",
64
+ "$.context.agent.*",
65
+ "$.arguments_redacted.*"
66
+ ],
67
+ audit_event_type="inspect_agent_request",
68
+ event_id=policy_request.event_id,
69
+ prompt_id=prompt_id)
70
+
71
+ async def inspect_policy_response(self, policy_response: PolicyResponse,
72
+ prompt_id: str) -> InspectDecision:
73
+ """Call inspect_policy_response API endpoint"""
74
+ if not self.client:
75
+ raise SecurityAPIError("Client not initialized - use async context manager")
76
+
77
+ return await self._make_request("/inspect_response", policy_response, method="POST",
78
+ redacted_keys=[
79
+ "$.tool.description",
80
+ "$.context.agent.*",
81
+ "$.result_preview.*"
82
+ ],
83
+ audit_event_type="inspect_mcp_response",
84
+ event_id=policy_response.event_id,
85
+ prompt_id=prompt_id)
86
+
87
+ async def record_user_confirmation(self, user_confirmation: UserConfirmation,
88
+ prompt_id: str) -> Dict[str, Any]:
89
+ """Record user confirmation decision"""
90
+ if not self.client:
91
+ raise SecurityAPIError("Client not initialized - use async context manager")
92
+
93
+ return await self._make_request("/user_confirmation", payload=user_confirmation, method="PUT",
94
+ # non existing key to skip redaction completely (nothing to redact here)
95
+ redacted_keys=["$.none"],
96
+ audit_event_type="record_user_confirmation",
97
+ event_id=user_confirmation.event_id,
98
+ prompt_id=prompt_id)
99
+
100
+ async def init_tools(self, init_request: InitRequest, event_id: Optional[str] = None) -> Dict[str, Any]:
101
+ """Initialize tools with environment, server, and tools data"""
102
+ if not self.client:
103
+ raise SecurityAPIError("Client not initialized - use async context manager")
104
+
105
+ payload = {
106
+ "environment": {
107
+ "session_id": init_request.environment.session_id,
108
+ "workspace": init_request.environment.workspace,
109
+ "client": init_request.environment.client,
110
+ "client_version": init_request.environment.client_version,
111
+ "selection_hash": init_request.environment.selection_hash
112
+ },
113
+ "server": {
114
+ "name": init_request.server.name,
115
+ "transport": init_request.server.transport,
116
+ "version": init_request.server.version
117
+ },
118
+ "tools": [
119
+ {
120
+ "name": tool.name,
121
+ "description": tool.description,
122
+ "version": tool.version
123
+ }
124
+ for tool in init_request.tools
125
+ ]
126
+ }
127
+
128
+ return await self._make_request("/init", payload, method="POST",
129
+ redacted_keys=["$.tools[*].description"],
130
+ audit_event_type="init_tools",
131
+ event_id=event_id,
132
+ prompt_id=None)
133
+
134
+ async def _make_request(self, endpoint: str, payload: Any, method: str,
135
+ audit_event_type: str, event_id: str = None,
136
+ prompt_id: str = None, redacted_keys: List[str] = None) -> Dict[str, Any]:
137
+ """Make HTTP request to security API"""
138
+ url = f"{self.base_url}{endpoint}"
139
+ error: Exception = None
140
+
141
+ try:
142
+ id = str(uuid.uuid4())[:5]
143
+
144
+ payload_dict = to_dict(payload)
145
+ redacted_payload = redact(payload_dict, include_keys=redacted_keys) if redacted_keys else payload_dict
146
+ redacted_payload_json = safe_json_dumps(redacted_payload)
147
+ self.logger.info(f"Security API request: {{'id': {id}, 'method': {method}, 'url': {url}, "
148
+ f"'payload': {redacted_payload_json}}}")
149
+
150
+ if "arguments_redacted" in payload_dict:
151
+ audit_payload = {"payload": payload_dict["arguments_redacted"]}
152
+ elif "result_preview" in payload_dict:
153
+ audit_payload = {"payload": payload_dict["result_preview"]}
154
+ elif "tools" in payload_dict and "server" in payload_dict:
155
+ audit_payload = {"payload": {"server": payload_dict["server"], "tools": payload_dict["tools"]}}
156
+ else:
157
+ audit_payload = {"payload": payload_dict}
158
+
159
+ self.audit_logger.log_event(
160
+ audit_event_type,
161
+ audit_payload,
162
+ event_id=event_id,
163
+ prompt_id=prompt_id,
164
+ include_keys=redacted_keys
165
+ )
166
+
167
+ headers = {
168
+ "Content-Type": "application/json",
169
+ "X-User-UID": self.user_id,
170
+ "X-App-UID": self.app_id
171
+ }
172
+
173
+ on_make_request_start_time = time.time()
174
+ method_upper = method.upper()
175
+ if method_upper == "PUT":
176
+ response = await self.client.put(
177
+ url,
178
+ content=redacted_payload_json,
179
+ headers=headers
180
+ )
181
+ elif method_upper == "POST":
182
+ response = await self.client.post(
183
+ url,
184
+ content=redacted_payload_json,
185
+ headers=headers
186
+ )
187
+ else:
188
+ raise SecurityAPIError(f"Unsupported HTTP method: {method}. Supported methods: POST, PUT")
189
+
190
+ on_make_request_duration = time.time() - on_make_request_start_time
191
+ self.logger.info(f"PROFILE: {method} id: {id} make_request duration: {on_make_request_duration:.2f} seconds url: {url}")
192
+
193
+ match response.status_code:
194
+ case 200:
195
+ data = response.json()
196
+ data_dict = to_dict(data);
197
+ self.logger.info(f"Security API response: {{'id': {id}, 'data': {data_dict}}}")
198
+
199
+ if "decision" in data_dict:
200
+ # InspectDecision response (/inspect_request, /inspect_response)
201
+ # Extract: decision, call_type, severity, reasons
202
+ audit_result = {"result": {"decision": data_dict["decision"]}}
203
+ if "call_type" in data_dict:
204
+ audit_result["result"]["call_type"] = data_dict["call_type"]
205
+ if "severity" in data_dict:
206
+ audit_result["result"]["severity"] = data_dict["severity"]
207
+ if "reasons" in data_dict:
208
+ audit_result["result"]["reasons"] = data_dict["reasons"]
209
+ elif "user_decision" in data_dict:
210
+ # UserConfirmation response (/user_confirmation)
211
+ # Extract: user_decision, call_type, confirmed_at
212
+ audit_result = {"result": {"user_decision": data_dict["user_decision"]}}
213
+ if "call_type" in data_dict:
214
+ audit_result["result"]["call_type"] = data_dict["call_type"]
215
+ else:
216
+ # Other responses (e.g., /init) - log entire response
217
+ audit_result = {"result": data_dict}
218
+
219
+ self.audit_logger.log_event(
220
+ f"{audit_event_type}_result",
221
+ audit_result,
222
+ event_id=event_id,
223
+ prompt_id=prompt_id,
224
+ include_keys=redacted_keys
225
+ )
226
+
227
+ # Successful response - handle quota restoration
228
+ self._handle_quota_restoration(endpoint)
229
+
230
+ return data
231
+ case 400:
232
+ error_data = response.json()
233
+ error_msg = error_data.get("error", "Bad request")
234
+ raise SecurityAPIError(f"Security API validation error: {error_msg}")
235
+ case 429:
236
+ error_data = response.json() if response.content else {}
237
+ retry_after = int(response.headers.get('Retry-After', '60'))
238
+
239
+ # Handle 429 - log, notify, and return allow decision (screening bypassed)
240
+ self.logger.error(f"Security API rate limit exhausted (429) - bypassing security screening. "
241
+ f"Endpoint: {endpoint}, Retry-After: {retry_after}s, Session: {self.session_id}")
242
+ self._send_throttled_quota_notification(retry_after, endpoint)
243
+
244
+ return {
245
+ "decision": "allow",
246
+ "severity": "high",
247
+ "reasons": ["Security quota exhausted - screening bypassed"]
248
+ }
249
+ case 500:
250
+ error_data = response.json()
251
+ error_msg = error_data.get("error", "Internal server error")
252
+ raise SecurityAPIError(f"Security API server error: {error_msg}")
253
+ case _:
254
+ raise SecurityAPIError(f"Security API returned status {response.status_code}")
255
+
256
+ except httpx.RequestError as e:
257
+ error = e
258
+ raise SecurityAPIError(f"Failed to connect to security API: {e}")
259
+ except json.JSONDecodeError as e:
260
+ error = e
261
+ raise SecurityAPIError(f"Invalid JSON response from security API: {e}")
262
+ except Exception as e:
263
+ error = e
264
+ raise SecurityAPIError(f"Unexpected error calling security API: {e}")
265
+ finally:
266
+ if error:
267
+ self.audit_logger.log_event(
268
+ f"{audit_event_type}_result",
269
+ {
270
+ "endpoint": endpoint,
271
+ "error": [f"Security API error: {error}"]
272
+ },
273
+ event_id=event_id,
274
+ prompt_id=prompt_id,
275
+ include_keys=redacted_keys
276
+ )
277
+
278
+ def _handle_quota_restoration(self, endpoint: str):
279
+ """Handle quota restoration (when non-429 response received)"""
280
+ if self.session_id in self._session_notification_times:
281
+ self.logger.info(f"Quota restored - received successful response from {endpoint}. Session: {self.session_id}")
282
+ del self._session_notification_times[self.session_id]
283
+
284
+ def _send_throttled_quota_notification(self, retry_after: int, endpoint: str):
285
+ """Send throttled quota notification to user"""
286
+ import time
287
+ from modules.ui import xdialog
288
+
289
+ try:
290
+ current_time = time.time()
291
+ one_hour = 3600
292
+
293
+ # Check if we should send notification (throttle to once per hour per session)
294
+ last_notification = self._session_notification_times.get(self.session_id)
295
+ should_send = (
296
+ last_notification is None or
297
+ (current_time - last_notification) >= one_hour
298
+ )
299
+
300
+ if not should_send:
301
+ time_since_last = current_time - last_notification
302
+ self.logger.debug(f"429 notification throttled (sent {time_since_last:.0f}s ago). "
303
+ f"Session: {self.session_id}, Endpoint: {endpoint}")
304
+ else:
305
+ message = (
306
+ "MCPower quota exhausted.\n\n"
307
+ "Subsequent requests will not be screened.\n\n"
308
+ "Please contact support if you need additional quota.\n\n"
309
+ )
310
+
311
+ xdialog.warning(
312
+ title="Warning: Security Quota Exhausted",
313
+ message=message
314
+ )
315
+
316
+ self._session_notification_times[self.session_id] = current_time
317
+
318
+ self.logger.warning(f"Displayed 429 quota exhaustion dialog to user. "
319
+ f"Session: {self.session_id}, Endpoint: {endpoint}")
320
+
321
+ except Exception as e:
322
+ self.logger.error(f"Failed to show quota exhaustion notification: {e}")
@@ -0,0 +1 @@
1
+ # Logs package
@@ -0,0 +1,162 @@
1
+ """
2
+ Audit Trail Logger for MCP Wrapper
3
+
4
+ Provides comprehensive transparency and accountability by logging all data flows
5
+ through the MCP wrapper in a user-facing, sequential JSON Lines format.
6
+
7
+ Captures complete request/response lifecycles including:
8
+ - Wrapper initialization
9
+ - Agent requests and policy decisions
10
+ - User confirmation interactions
11
+ - Data forwarding and responses
12
+ - API communications with policy service
13
+
14
+ All data is automatically redacted for PII and secrets before logging.
15
+ """
16
+
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+ from typing import Any, Dict, List, Optional
20
+
21
+ from modules.logs.logger import MCPLogger
22
+ from modules.redaction.redactor import redact
23
+ from modules.utils.config import get_audit_trail_path
24
+ from modules.utils.ids import get_session_id
25
+ from modules.utils.json import safe_json_dumps, to_dict
26
+
27
+
28
+ class AuditTrailLogger:
29
+ """
30
+ Audit trail logger for transparent MCP wrapper operations
31
+
32
+ Logs all data flows through the wrapper in JSON Lines format for user transparency.
33
+ Each log entry represents one step in the sequential flow of MCP operations.
34
+ """
35
+
36
+ def __init__(self, logger: MCPLogger):
37
+ """
38
+ Initialize audit trail logger
39
+
40
+ Args:
41
+ logger: Existing MCPLogger instance for error reporting
42
+ """
43
+ self.logger = logger
44
+ self.app_uid: Optional[str] = None # Will be set by middleware after roots are available
45
+ self.session_id = get_session_id()
46
+ self.audit_file = get_audit_trail_path()
47
+ self._pending_logs: List[Dict[str, Any]] = [] # Queue logs until app_uid is set
48
+
49
+ # Ensure audit trail file directory exists
50
+ Path(self.audit_file).parent.mkdir(parents=True, exist_ok=True)
51
+
52
+ def log_event(
53
+ self,
54
+ event_type: str,
55
+ data: Dict[str, Any],
56
+ event_id: Optional[str] = None,
57
+ prompt_id: Optional[str] = None,
58
+ user_prompt: Optional[str] = None,
59
+ ignored_keys: Optional[List[str]] = None,
60
+ include_keys: Optional[List[str]] = None
61
+ ):
62
+ """
63
+ Log a single audit event
64
+
65
+ Args:
66
+ event_type: Type of audit event (e.g., 'mcpower_start', 'agent_request')
67
+ data: Event-specific data dictionary (will be automatically redacted)
68
+ event_id: Optional event correlation ID (for pairing request/response)
69
+ prompt_id: Optional user prompt correlation ID (for grouping tool calls by prompt)
70
+ user_prompt: Optional user prompt text (stored once per prompt_id)
71
+ ignored_keys: Optional list of JSONPath patterns to ignore during redaction
72
+ include_keys: Optional list of JSONPath patterns to redact (all others ignored)
73
+ """
74
+ try:
75
+ # Convert data to dict structure (handles nested objects, dataclasses, Pydantic models)
76
+ data_dict = to_dict(data)
77
+
78
+ # Build event structure
79
+ event = {
80
+ "session_id": self.session_id,
81
+ "timestamp": datetime.now(timezone.utc).isoformat(),
82
+ "event_type": event_type,
83
+ "data": redact(data_dict, ignored_keys=ignored_keys, include_keys=include_keys) # Redaction with optional key filtering
84
+ }
85
+
86
+ # Include prompt_id if provided (for grouping by user prompt)
87
+ if prompt_id:
88
+ event["prompt_id"] = prompt_id
89
+
90
+ # Include user_prompt text if provided (only needed once per prompt_id)
91
+ if user_prompt:
92
+ event["user_prompt"] = user_prompt
93
+
94
+ # Include event_id if provided (for pairing request/response)
95
+ if event_id:
96
+ event["event_id"] = event_id
97
+
98
+ # If app_uid not set yet, queue the log
99
+ if self.app_uid is None:
100
+ self._pending_logs.append(event)
101
+ else:
102
+ # app_uid is available, write immediately
103
+ self._write_event(event)
104
+
105
+ except Exception as e:
106
+ # Log errors to existing logger but continue operation
107
+ self.logger.error(f"Audit trail write failed: {e}")
108
+
109
+ def _write_event(self, event: Dict[str, Any]):
110
+ """
111
+ Write a single event to the audit trail file with app_uid as first key
112
+
113
+ Args:
114
+ event: Event dict (may or may not have app_uid already)
115
+ """
116
+ # Ensure app_uid is first key in the output
117
+ event_with_app_uid = {
118
+ "app_uid": self.app_uid,
119
+ **{k: v for k, v in event.items() if k != "app_uid"}
120
+ }
121
+
122
+ # Atomic append to audit trail file
123
+ with open(self.audit_file, 'a', encoding='utf-8') as f:
124
+ f.write(safe_json_dumps(event_with_app_uid) + '\n')
125
+ f.flush() # Force immediate write for crash safety
126
+
127
+ def set_app_uid(self, app_uid: str):
128
+ """
129
+ Set the app_uid and flush all pending logs to file
130
+
131
+ This is called by the middleware after workspace roots are available.
132
+ All queued logs will be written with app_uid as the first key.
133
+
134
+ Args:
135
+ app_uid: The application UID from workspace root
136
+ """
137
+ if self.app_uid is not None:
138
+ self.logger.warning(f"app_uid already set to {self.app_uid}, ignoring new value {app_uid}")
139
+ return
140
+
141
+ self.app_uid = app_uid
142
+ self.logger.debug(f"✅ app_uid set to: {app_uid}")
143
+
144
+ # Flush all pending logs
145
+ if self._pending_logs:
146
+ self.logger.debug(f"Flushing {len(self._pending_logs)} queued audit logs")
147
+ for event in self._pending_logs:
148
+ self._write_event(event)
149
+ self._pending_logs.clear()
150
+
151
+
152
+ def setup_audit_trail_logger(logger: MCPLogger) -> AuditTrailLogger:
153
+ """
154
+ Create audit trail logger instance
155
+
156
+ Args:
157
+ logger: Existing MCPLogger instance for error reporting
158
+
159
+ Returns:
160
+ Configured AuditTrailLogger instance
161
+ """
162
+ return AuditTrailLogger(logger)
modules/logs/logger.py ADDED
@@ -0,0 +1,128 @@
1
+ """
2
+ Simple line-based logger for MCP traffic and wrapped MCP logs
3
+ Implements the required logging format for all MCP operations
4
+ """
5
+
6
+ import logging
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Optional, TextIO
10
+
11
+ from modules.utils.ids import get_session_id
12
+
13
+
14
+ class UTF8StreamHandler(logging.StreamHandler):
15
+ """StreamHandler that forces UTF-8 encoding on Windows to prevent UnicodeEncodeError"""
16
+
17
+ def __init__(self, stream=None):
18
+ # On Windows, wrap the stream with UTF-8 encoding BEFORE passing to parent
19
+ if sys.platform == 'win32' and stream is not None:
20
+ import io
21
+ if hasattr(stream, 'buffer'):
22
+ stream = io.TextIOWrapper(
23
+ stream.buffer,
24
+ encoding='utf-8',
25
+ errors='replace',
26
+ line_buffering=True
27
+ )
28
+
29
+ super().__init__(stream)
30
+
31
+
32
+ class SessionFormatter(logging.Formatter):
33
+ """Custom formatter that includes session ID in log messages"""
34
+
35
+ # Single character mapping for perfect alignment and compactness
36
+ LEVEL_MAPPING = {
37
+ 'DEBUG': 'D',
38
+ 'INFO': 'I',
39
+ 'WARNING': 'W',
40
+ 'ERROR': 'E',
41
+ 'CRITICAL': 'C'
42
+ }
43
+
44
+ def __init__(self, *args, **kwargs):
45
+ super().__init__(*args, **kwargs)
46
+ self._session_id = get_session_id()[:8]
47
+
48
+ def format(self, record):
49
+ # Use the cached session ID
50
+ record.session_id = self._session_id
51
+ # Override levelname with single character version
52
+ if not record.levelname or not isinstance(record.levelname, str):
53
+ record.levelname = 'U' # Unknown
54
+ else:
55
+ record.levelname = self.LEVEL_MAPPING.get(record.levelname, record.levelname[0])
56
+ return super().format(record)
57
+
58
+
59
+ class MCPLogger:
60
+ """Simple line-based logger for MCP traffic"""
61
+
62
+ def __init__(self, log_file: Optional[str] = None, level: int = logging.INFO):
63
+ self.log_file = log_file
64
+ self.file_handle: Optional[TextIO] = None
65
+
66
+ # Setup file handle if log file specified (for MCP traffic logging)
67
+ if log_file:
68
+ log_path = Path(log_file)
69
+ log_path.parent.mkdir(parents=True, exist_ok=True)
70
+ self.file_handle = open(log_path, 'a', encoding='utf-8')
71
+
72
+ # Setup standard logger for non-MCP messages
73
+ self.logger = logging.getLogger('mcpower')
74
+ self.logger.setLevel(level)
75
+
76
+ # Create console handler with UTF-8 support
77
+ console_handler = UTF8StreamHandler(sys.stderr)
78
+ console_handler.setLevel(level)
79
+ formatter = SessionFormatter('%(asctime)s [%(session_id)s] (%(levelname)s) %(message)s')
80
+ console_handler.setFormatter(formatter)
81
+ self.logger.addHandler(console_handler)
82
+
83
+ # Add file handler if log file specified
84
+ if log_file:
85
+ file_handler = logging.FileHandler(log_file, encoding='utf-8')
86
+ file_handler.setLevel(level)
87
+ file_handler.setFormatter(formatter)
88
+ self.logger.addHandler(file_handler)
89
+
90
+
91
+ def info(self, message: str) -> None:
92
+ """Log info message"""
93
+ self.logger.info(message)
94
+
95
+ def error(self, message: str, exc_info: bool = False) -> None:
96
+ """Log error message"""
97
+ self.logger.error(message, exc_info=exc_info)
98
+
99
+ def warning(self, message: str) -> None:
100
+ """Log warning message"""
101
+ self.logger.warning(message)
102
+
103
+ def debug(self, message: str) -> None:
104
+ """Log debug message"""
105
+ self.logger.debug(message)
106
+
107
+ def close(self) -> None:
108
+ """Close log file handle"""
109
+ if self.file_handle:
110
+ self.file_handle.close()
111
+ self.file_handle = None
112
+
113
+
114
+ def setup_logger(log_file: Optional[str] = None, level: int = logging.INFO) -> MCPLogger:
115
+ """
116
+ Setup MCP logger with specified configuration
117
+
118
+ Args:
119
+ log_file: Optional path to log file (uses stdout if None)
120
+ level: Logging level
121
+
122
+ Returns:
123
+ Configured MCPLogger instance
124
+ """
125
+ return MCPLogger(log_file, level)
126
+
127
+
128
+
@@ -0,0 +1,13 @@
1
+ """
2
+ Client-side data redaction module for PII and secrets detection.
3
+
4
+ Uses regex patterns with zero external dependencies:
5
+ - Custom regex patterns for PII detection
6
+ - Gitleaks-based patterns for secrets detection
7
+
8
+ Fully offline, deterministic, and idempotent.
9
+ """
10
+
11
+ from .redactor import redact
12
+
13
+ __all__ = ['redact']
@@ -0,0 +1,38 @@
1
+ """
2
+ Constants for client-side redaction.
3
+ """
4
+
5
+ # PII entity type to placeholder mappings
6
+ PII_PLACEHOLDERS = {
7
+ "CREDIT_CARD": "[REDACTED-CREDIT-CARD]",
8
+ "CRYPTO_ADDRESS": "[REDACTED-CRYPTO]",
9
+ "EMAIL_ADDRESS": "[REDACTED-EMAIL]",
10
+ "IBAN": "[REDACTED-IBAN]",
11
+ "IP_ADDRESS": "[REDACTED-IP]",
12
+ "LOCATION": "[REDACTED-LOCATION]",
13
+ "PERSON": "[REDACTED-PERSON]",
14
+ "PHONE_NUMBER": "[REDACTED-PHONE]",
15
+ "MEDICAL_LICENSE": "[REDACTED-MEDICAL-LICENSE]",
16
+ "URL": "[REDACTED-URL]",
17
+ "US_BANK_NUMBER": "[REDACTED-BANK-NUMBER]",
18
+ "US_DRIVER_LICENSE": "[REDACTED-DRIVER-LICENSE]",
19
+ "US_ITIN": "[REDACTED-ITIN]",
20
+ "US_PASSPORT": "[REDACTED-PASSPORT]",
21
+ "US_SSN": "[REDACTED-SSN]",
22
+ # Default fallback for any other entity types
23
+ "DEFAULT": "[REDACTED-PII]"
24
+ }
25
+
26
+ # Secrets detection placeholder
27
+ SECRETS_PLACEHOLDER = "[REDACTED-SECRET]"
28
+
29
+ # Pattern to match existing redaction placeholders (for idempotency)
30
+ REDACTION_PLACEHOLDER_PATTERN = r'\[REDACTED-[A-Z-]+\]'
31
+
32
+ # Zero-width characters to normalize
33
+ ZERO_WIDTH_CHARS = [
34
+ '\u200b', # Zero Width Space
35
+ '\u200c', # Zero Width Non-Joiner
36
+ '\u200d', # Zero Width Joiner
37
+ '\ufeff', # Zero Width No-Break Space (BOM)
38
+ ]