langprotect-mcp-gateway 1.0.0__tar.gz → 1.1.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langprotect-mcp-gateway
3
- Version: 1.0.0
3
+ Version: 1.1.0
4
4
  Summary: Security gateway for Model Context Protocol (MCP) to protect AI tool interactions
5
5
  Author-email: LangProtect Security Team <security@langprotect.com>
6
6
  License: MIT
@@ -14,7 +14,7 @@ Or via command line:
14
14
  langprotect-gateway
15
15
  """
16
16
 
17
- __version__ = '1.0.0'
17
+ __version__ = '1.1.0'
18
18
  __author__ = 'LangProtect Security Team'
19
19
  __license__ = 'MIT'
20
20
 
@@ -0,0 +1,321 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ LangProtect MCP Gateway - Security Gateway for MCP Servers
4
+ """
5
+
6
+ import sys
7
+ import json
8
+ import os
9
+ import subprocess
10
+ import requests
11
+ import argparse
12
+ from datetime import datetime, timedelta
13
+ from typing import Dict, List, Any, Optional
14
+ import logging
15
+
16
+ log_level = os.environ.get("LOGLEVEL", "DEBUG" if os.getenv('DEBUG', 'false').lower() == 'true' else "INFO").upper()
17
+ logging.basicConfig(level=getattr(logging, log_level), format='[%(asctime)s] %(levelname)s: %(message)s', handlers=[logging.StreamHandler(sys.stderr)])
18
+ logger = logging.getLogger('langprotect-gateway')
19
+
20
+
21
+ class MCPServer:
22
+ def __init__(self, name: str, config: Dict[str, Any]):
23
+ self.name = name
24
+ self.command = config.get('command')
25
+ self.args = config.get('args', [])
26
+ self.env = config.get('env', {})
27
+ self.tools: List[Dict] = []
28
+ self.process: Optional[subprocess.Popen] = None
29
+ self._request_id = 0
30
+ logger.info(f"Configured MCP server: {name} ({self.command} {' '.join(self.args)})")
31
+
32
+ def _next_id(self) -> int:
33
+ self._request_id += 1
34
+ return self._request_id
35
+
36
+ def start(self) -> bool:
37
+ try:
38
+ env = {**os.environ, **self.env}
39
+ self.process = subprocess.Popen([self.command] + self.args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=env, bufsize=1)
40
+ init_request = {"jsonrpc": "2.0", "id": self._next_id(), "method": "initialize", "params": {"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "langprotect-gateway", "version": "1.0.0"}}}
41
+ self.process.stdin.write(json.dumps(init_request) + "\n")
42
+ self.process.stdin.flush()
43
+ response_line = self.process.stdout.readline()
44
+ if not response_line:
45
+ logger.error(f"Failed to initialize {self.name}")
46
+ return False
47
+ response = json.loads(response_line)
48
+ if "error" in response:
49
+ logger.error(f"Initialize error for {self.name}: {response['error']}")
50
+ return False
51
+ self.process.stdin.write(json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}) + "\n")
52
+ self.process.stdin.flush()
53
+ logger.info(f"Started MCP server: {self.name}")
54
+ return True
55
+ except Exception as e:
56
+ logger.error(f"Failed to start {self.name}: {e}")
57
+ return False
58
+
59
+ def stop(self):
60
+ if self.process:
61
+ try:
62
+ self.process.terminate()
63
+ self.process.wait(timeout=5)
64
+ except:
65
+ self.process.kill()
66
+ self.process = None
67
+
68
+ def call(self, method: str, params: Dict) -> Dict:
69
+ if not self.process or self.process.poll() is not None:
70
+ raise Exception(f"Server {self.name} is not running")
71
+ request = {"jsonrpc": "2.0", "id": self._next_id(), "method": method, "params": params}
72
+ logger.debug(f"-> {self.name}.{method}")
73
+ try:
74
+ self.process.stdin.write(json.dumps(request) + "\n")
75
+ self.process.stdin.flush()
76
+ response_line = self.process.stdout.readline()
77
+ if not response_line:
78
+ raise Exception(f"No response from {self.name}")
79
+ response = json.loads(response_line)
80
+ logger.debug(f"<- {self.name}: {str(response)[:200]}")
81
+ return response
82
+ except Exception as e:
83
+ logger.error(f"Error calling {self.name}.{method}: {e}")
84
+ raise
85
+
86
+ def discover_tools(self) -> List[Dict]:
87
+ try:
88
+ response = self.call("tools/list", {})
89
+ if "result" in response:
90
+ self.tools = response["result"].get("tools", [])
91
+ logger.info(f"Discovered {len(self.tools)} tools from {self.name}")
92
+ return self.tools
93
+ return []
94
+ except Exception as e:
95
+ logger.warning(f"Could not discover tools from {self.name}: {e}")
96
+ return []
97
+
98
+
99
+ class LangProtectAuth:
100
+ def __init__(self, url: str, email: str, password: str):
101
+ self.url = url
102
+ self.email = email
103
+ self.password = password
104
+ self.jwt_token: Optional[str] = None
105
+ self.token_expiry: Optional[datetime] = None
106
+
107
+ def login(self) -> bool:
108
+ try:
109
+ logger.info(f"Authenticating with {self.url}...")
110
+ response = requests.post(f"{self.url}/v1/group-users/signin", json={'email': self.email, 'password': self.password}, timeout=10)
111
+ if response.status_code == 200:
112
+ data = response.json()
113
+ self.jwt_token = data.get('access_token')
114
+ self.token_expiry = datetime.now() + timedelta(days=6)
115
+ logger.info("Authentication successful")
116
+ return True
117
+ else:
118
+ logger.error(f"Login failed: {response.status_code}")
119
+ return False
120
+ except Exception as e:
121
+ logger.error(f"Login error: {e}")
122
+ return False
123
+
124
+ def ensure_token(self) -> bool:
125
+ if not self.jwt_token or (self.token_expiry and datetime.now() > self.token_expiry):
126
+ return self.login()
127
+ return True
128
+
129
+ def scan(self, tool_name: str, arguments: Dict, server_name: str) -> Dict:
130
+ self.ensure_token()
131
+ try:
132
+ payload = {'prompt': json.dumps({'tool': tool_name, 'arguments': arguments, 'server': server_name}), 'client_ip': '127.0.0.1', 'user_agent': f'LangProtect-MCP-Gateway/1.0 (server={server_name})', 'source': 'mcp-gateway'}
133
+ response = requests.post(f"{self.url}/v1/group-logs/scan", json=payload, headers={'Authorization': f'Bearer {self.jwt_token}', 'Content-Type': 'application/json'}, timeout=5)
134
+ if response.status_code != 200:
135
+ logger.warning(f"Backend returned {response.status_code}, allowing request (fail-open)")
136
+ return {'status': 'allowed', 'error': f'Backend error: {response.status_code}'}
137
+ result = response.json()
138
+ # Handle scan service timeout - fail open
139
+ if result.get('detections', {}).get('error') == 'Scan service timeout':
140
+ logger.warning("Scan service timeout, allowing request (fail-open)")
141
+ return {'status': 'allowed', 'id': result.get('id'), 'error': 'Scan timeout'}
142
+ return result
143
+ except requests.exceptions.Timeout:
144
+ logger.warning("Backend scan timeout, allowing request (fail-open)")
145
+ return {'status': 'allowed', 'error': 'Request timeout'}
146
+ except Exception as e:
147
+ logger.error(f"Scan error: {e}")
148
+ return {'status': 'allowed', 'error': str(e)}
149
+
150
+
151
+ class LangProtectGateway:
152
+ def __init__(self, mcp_json_path: Optional[str] = None):
153
+ self.langprotect_url = os.getenv('LANGPROTECT_URL', 'http://localhost:8000')
154
+ self.email = os.getenv('LANGPROTECT_EMAIL')
155
+ self.password = os.getenv('LANGPROTECT_PASSWORD')
156
+ self.mcp_json_path = mcp_json_path
157
+ self.auth: Optional[LangProtectAuth] = None
158
+ self.mcp_servers: Dict[str, MCPServer] = {}
159
+ self.tool_to_server: Dict[str, str] = {}
160
+ self.all_tools: List[Dict] = []
161
+ logger.debug(f"LANGPROTECT_URL: {self.langprotect_url}")
162
+ logger.debug(f"LANGPROTECT_EMAIL: {self.email}")
163
+
164
+ def initialize(self) -> bool:
165
+ if self.email and self.password:
166
+ self.auth = LangProtectAuth(self.langprotect_url, self.email, self.password)
167
+ if not self.auth.login():
168
+ logger.error("Failed to authenticate with LangProtect backend")
169
+ return False
170
+ else:
171
+ logger.warning("No LangProtect credentials - running in pass-through mode")
172
+ if not self.load_servers():
173
+ return False
174
+ if not self.start_servers():
175
+ return False
176
+ logger.info("=" * 50)
177
+ logger.info("LangProtect Gateway initialized")
178
+ logger.info(f"Backend: {self.langprotect_url}")
179
+ logger.info(f"Servers: {len(self.mcp_servers)}")
180
+ logger.info(f"Tools: {len(self.all_tools)}")
181
+ logger.info("=" * 50)
182
+ return True
183
+
184
+ def load_servers(self) -> bool:
185
+ mcp_command = os.getenv('MCP_SERVER_COMMAND')
186
+ mcp_args = os.getenv('MCP_SERVER_ARGS')
187
+ if mcp_command:
188
+ logger.info(f"Single server mode: {mcp_command}")
189
+ args_list = [arg.strip() for arg in mcp_args.split(',')] if mcp_args else []
190
+ server_name = os.getenv('MCP_SERVER_NAME', 'proxied-server')
191
+ self.mcp_servers[server_name] = MCPServer(server_name, {'command': mcp_command, 'args': args_list, 'env': {}})
192
+ return True
193
+ if self.mcp_json_path:
194
+ return self.load_from_mcp_json(self.mcp_json_path)
195
+ logger.warning("No MCP servers configured")
196
+ return False
197
+
198
+ def load_from_mcp_json(self, path: str) -> bool:
199
+ try:
200
+ with open(path, 'r') as f:
201
+ config = json.load(f)
202
+ servers = config.get('servers', config.get('mcpServers', {}))
203
+ if not servers:
204
+ return False
205
+ for name, cfg in servers.items():
206
+ if name not in ['langprotect-gateway', 'langprotect']:
207
+ self.mcp_servers[name] = MCPServer(name, cfg)
208
+ return len(self.mcp_servers) > 0
209
+ except Exception as e:
210
+ logger.error(f"Error loading {path}: {e}")
211
+ return False
212
+
213
+ def start_servers(self) -> bool:
214
+ started = 0
215
+ for name, server in list(self.mcp_servers.items()):
216
+ if server.start():
217
+ tools = server.discover_tools()
218
+ for tool in tools:
219
+ tool_name = tool.get('name')
220
+ if tool_name:
221
+ self.tool_to_server[tool_name] = name
222
+ tool_copy = tool.copy()
223
+ tool_copy['description'] = f"[{name}] {tool_copy.get('description', '')}"
224
+ self.all_tools.append(tool_copy)
225
+ started += 1
226
+ else:
227
+ del self.mcp_servers[name]
228
+ return started > 0
229
+
230
+ def shutdown(self):
231
+ for server in self.mcp_servers.values():
232
+ server.stop()
233
+
234
+ def handle_request(self, request: Dict) -> Optional[Dict]:
235
+ method = request.get('method')
236
+ request_id = request.get('id')
237
+ params = request.get('params', {})
238
+ logger.info(f"Request: {method} (id={request_id})")
239
+ try:
240
+ if method == 'initialize':
241
+ return {'jsonrpc': '2.0', 'id': request_id, 'result': {'protocolVersion': '2024-11-05', 'capabilities': {'tools': {}}, 'serverInfo': {'name': 'langprotect-gateway', 'version': '1.0.0'}}}
242
+ elif method == 'notifications/initialized':
243
+ return None
244
+ elif method == 'tools/list':
245
+ logger.info(f"Returning {len(self.all_tools)} tools")
246
+ return {'jsonrpc': '2.0', 'id': request_id, 'result': {'tools': self.all_tools}}
247
+ elif method == 'tools/call':
248
+ return self._handle_call_tool(request_id, params)
249
+ else:
250
+ return {'jsonrpc': '2.0', 'id': request_id, 'error': {'code': -32601, 'message': f'Method not found: {method}'}}
251
+ except Exception as e:
252
+ logger.error(f"Error handling {method}: {e}")
253
+ return {'jsonrpc': '2.0', 'id': request_id, 'error': {'code': -32603, 'message': str(e)}}
254
+
255
+ def _handle_call_tool(self, request_id, params: Dict) -> Dict:
256
+ tool_name = params.get('name', '')
257
+ arguments = params.get('arguments', {})
258
+ server_name = self.tool_to_server.get(tool_name)
259
+ if not server_name:
260
+ return {'jsonrpc': '2.0', 'id': request_id, 'error': {'code': -32602, 'message': f'Unknown tool: {tool_name}'}}
261
+ server = self.mcp_servers.get(server_name)
262
+ if not server:
263
+ return {'jsonrpc': '2.0', 'id': request_id, 'error': {'code': -32602, 'message': f'Server not found: {server_name}'}}
264
+ logger.info(f"Tool call: {server_name}.{tool_name}")
265
+ if self.auth:
266
+ scan_result = self.auth.scan(tool_name, arguments, server_name)
267
+ status = scan_result.get('status', '').lower()
268
+ if status == 'blocked':
269
+ reason = scan_result.get('detections', {}).get('MCPActionControl', {}).get('reason', 'Policy violation')
270
+ logger.warning(f"BLOCKED: {tool_name} - {reason}")
271
+ return {'jsonrpc': '2.0', 'id': request_id, 'error': {'code': -32000, 'message': f'LangProtect: {reason}'}}
272
+ logger.info(f"ALLOWED (log_id={scan_result.get('id')})")
273
+ try:
274
+ response = server.call('tools/call', {'name': tool_name, 'arguments': arguments})
275
+ if 'result' in response:
276
+ return {'jsonrpc': '2.0', 'id': request_id, 'result': response['result']}
277
+ elif 'error' in response:
278
+ return {'jsonrpc': '2.0', 'id': request_id, 'error': response['error']}
279
+ return response
280
+ except Exception as e:
281
+ return {'jsonrpc': '2.0', 'id': request_id, 'error': {'code': -32603, 'message': f'Error executing tool: {e}'}}
282
+
283
+ def run(self):
284
+ try:
285
+ for line in sys.stdin:
286
+ line = line.strip()
287
+ if not line:
288
+ continue
289
+ try:
290
+ request = json.loads(line)
291
+ response = self.handle_request(request)
292
+ if response:
293
+ print(json.dumps(response), flush=True)
294
+ except json.JSONDecodeError as e:
295
+ print(json.dumps({'jsonrpc': '2.0', 'error': {'code': -32700, 'message': f'Parse error: {e}'}}), flush=True)
296
+ except KeyboardInterrupt:
297
+ pass
298
+ finally:
299
+ self.shutdown()
300
+
301
+
302
+ def parse_args():
303
+ parser = argparse.ArgumentParser(description='LangProtect MCP Gateway')
304
+ parser.add_argument('--mcp-json-path', type=str, help='Path to mcp.json configuration file')
305
+ parser.add_argument('--debug', action='store_true', help='Enable debug logging')
306
+ return parser.parse_args()
307
+
308
+
309
+ def main():
310
+ args = parse_args()
311
+ if args.debug:
312
+ os.environ['DEBUG'] = 'true'
313
+ logging.getLogger('langprotect-gateway').setLevel(logging.DEBUG)
314
+ gateway = LangProtectGateway(mcp_json_path=args.mcp_json_path)
315
+ if not gateway.initialize():
316
+ sys.exit(1)
317
+ gateway.run()
318
+
319
+
320
+ if __name__ == '__main__':
321
+ main()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langprotect-mcp-gateway
3
- Version: 1.0.0
3
+ Version: 1.1.0
4
4
  Summary: Security gateway for Model Context Protocol (MCP) to protect AI tool interactions
5
5
  Author-email: LangProtect Security Team <security@langprotect.com>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "langprotect-mcp-gateway"
7
- version = "1.0.0"
7
+ version = "1.1.0"
8
8
  description = "Security gateway for Model Context Protocol (MCP) to protect AI tool interactions"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,528 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- LangProtect MCP Gateway - Universal MCP Logging & Security
4
-
5
- This gateway intercepts ALL MCP tool calls from any client (Claude, VS Code, etc.)
6
- and logs them to LangProtect backend for security scanning and auditing.
7
-
8
- Architecture:
9
- 1. Reads user's MCP config to discover other MCP servers
10
- 2. Implements MCP protocol (initialize, list_tools, call_tool)
11
- 3. Logs every tool call to LangProtect backend
12
- 4. Forwards requests to actual MCP servers
13
- 5. Returns responses to AI client
14
-
15
- Usage:
16
- python3 langprotect_gateway.py
17
-
18
- Configuration (via environment variables):
19
- LANGPROTECT_URL - Backend API URL (default: http://localhost:8000)
20
- LANGPROTECT_EMAIL - User email for authentication
21
- LANGPROTECT_PASSWORD - User password
22
- MCP_CONFIG_PATH - Path to mcp.json (auto-detected if not set)
23
- DEBUG - Enable debug logging (true/false)
24
- """
25
-
26
- import sys
27
- import json
28
- import os
29
- import subprocess
30
- import requests
31
- import threading
32
- import time
33
- from datetime import datetime, timedelta
34
- from pathlib import Path
35
- from typing import Dict, List, Any, Optional
36
- import logging
37
-
38
- # Configure logging
39
- logging.basicConfig(
40
- level=logging.DEBUG if os.getenv('DEBUG', 'false').lower() == 'true' else logging.INFO,
41
- format='[%(asctime)s] %(levelname)s: %(message)s',
42
- handlers=[logging.StreamHandler(sys.stderr)]
43
- )
44
- logger = logging.getLogger('langprotect-gateway')
45
-
46
-
47
- class MCPServer:
48
- """Represents a single MCP server that can be called"""
49
-
50
- def __init__(self, name: str, config: Dict[str, Any]):
51
- self.name = name
52
- self.command = config.get('command')
53
- self.args = config.get('args', [])
54
- self.env = config.get('env', {})
55
- self.tools = [] # Will be populated by discovery
56
- self.process = None
57
- logger.info(f"Initialized MCP server: {name}")
58
-
59
- def call(self, method: str, params: Dict) -> Dict:
60
- """Call this MCP server with a method and params"""
61
- try:
62
- # Start process for this call
63
- env = {**os.environ, **self.env}
64
-
65
- self.process = subprocess.Popen(
66
- [self.command] + self.args,
67
- stdin=subprocess.PIPE,
68
- stdout=subprocess.PIPE,
69
- stderr=subprocess.PIPE,
70
- text=True,
71
- env=env
72
- )
73
-
74
- # Send request
75
- request = {
76
- "jsonrpc": "2.0",
77
- "id": 1,
78
- "method": method,
79
- "params": params
80
- }
81
-
82
- logger.debug(f"Calling {self.name}.{method}: {json.dumps(request)}")
83
-
84
- self.process.stdin.write(json.dumps(request) + "\n")
85
- self.process.stdin.flush()
86
-
87
- # Read response
88
- response_line = self.process.stdout.readline()
89
- if not response_line:
90
- error_output = self.process.stderr.read()
91
- raise Exception(f"No response from {self.name}: {error_output}")
92
-
93
- response = json.loads(response_line)
94
-
95
- # Cleanup
96
- self.process.terminate()
97
- self.process.wait(timeout=1)
98
-
99
- logger.debug(f"Response from {self.name}: {json.dumps(response)[:200]}")
100
-
101
- return response
102
-
103
- except Exception as e:
104
- logger.error(f"Error calling {self.name}: {e}")
105
- if self.process:
106
- self.process.terminate()
107
- raise
108
-
109
- def discover_tools(self) -> List[Dict]:
110
- """Discover what tools this MCP server provides"""
111
- try:
112
- response = self.call("tools/list", {})
113
- if "result" in response:
114
- self.tools = response["result"].get("tools", [])
115
- logger.info(f"Discovered {len(self.tools)} tools from {self.name}")
116
- return self.tools
117
- return []
118
- except Exception as e:
119
- logger.warning(f"Could not discover tools from {self.name}: {e}")
120
- return []
121
-
122
-
123
- class LangProtectGateway:
124
- """Main gateway that intercepts and logs all MCP traffic"""
125
-
126
- def __init__(self):
127
- self.langprotect_url = os.getenv('LANGPROTECT_URL', 'http://localhost:8000')
128
- self.email = os.getenv('LANGPROTECT_EMAIL')
129
- self.password = os.getenv('LANGPROTECT_PASSWORD')
130
- self.jwt_token = None
131
- self.token_expiry = None
132
- self.mcp_servers = {} # name -> MCPServer
133
- self.tool_to_server = {} # tool_name -> server_name
134
-
135
- # Debug: Log what we received
136
- logger.debug(f"Environment variables received:")
137
- logger.debug(f" LANGPROTECT_URL: {self.langprotect_url}")
138
- logger.debug(f" LANGPROTECT_EMAIL: {self.email}")
139
- logger.debug(f" LANGPROTECT_PASSWORD: {'***' + self.password[-4:] if self.password else 'NOT SET'}")
140
-
141
- # Validate credentials
142
- if not self.email or not self.password:
143
- logger.error("LANGPROTECT_EMAIL and LANGPROTECT_PASSWORD must be set")
144
- sys.exit(1)
145
-
146
- # Authenticate
147
- self.login()
148
-
149
- # Discover MCP servers
150
- self.discover_mcp_servers()
151
-
152
- # Start token refresh thread
153
- self.start_token_refresh()
154
-
155
- logger.info("LangProtect Gateway initialized successfully")
156
-
157
- def login(self):
158
- """Authenticate with LangProtect backend and get JWT token"""
159
- try:
160
- logger.info(f"Logging in to {self.langprotect_url}...")
161
-
162
- response = requests.post(
163
- f"{self.langprotect_url}/v1/group-users/signin",
164
- json={
165
- 'email': self.email,
166
- 'password': self.password
167
- },
168
- timeout=10
169
- )
170
-
171
- if response.status_code == 200:
172
- data = response.json()
173
- self.jwt_token = data.get('access_token')
174
- self.token_expiry = datetime.now() + timedelta(days=6)
175
- logger.info("✅ Authentication successful")
176
- return True
177
- else:
178
- logger.error(f"❌ Login failed: {response.status_code} - {response.text}")
179
- sys.exit(1)
180
-
181
- except Exception as e:
182
- logger.error(f"❌ Login error: {e}")
183
- sys.exit(1)
184
-
185
- def ensure_token(self):
186
- """Ensure we have a valid JWT token"""
187
- if not self.jwt_token or (self.token_expiry and datetime.now() > self.token_expiry):
188
- logger.info("Token expired, re-authenticating...")
189
- return self.login()
190
- return True
191
-
192
- def start_token_refresh(self):
193
- """Start background thread to refresh JWT token"""
194
- def refresh_loop():
195
- while True:
196
- time.sleep(86400) # Check daily
197
- if self.token_expiry and datetime.now() > self.token_expiry:
198
- logger.info("Auto-refreshing token...")
199
- self.login()
200
-
201
- refresh_thread = threading.Thread(target=refresh_loop, daemon=True)
202
- refresh_thread.start()
203
- logger.debug("Token refresh thread started")
204
-
205
- def discover_mcp_servers(self):
206
- """Find and load MCP servers from user's config"""
207
- config_paths = [
208
- os.getenv('MCP_CONFIG_PATH'),
209
- os.path.expanduser('~/.cursor/mcp.json'),
210
- os.path.expanduser('~/.config/Claude/claude_desktop_config.json'),
211
- os.path.expanduser('~/Library/Application Support/Claude/claude_desktop_config.json')
212
- ]
213
-
214
- for path in config_paths:
215
- if not path:
216
- continue
217
-
218
- path = os.path.expanduser(path)
219
- if os.path.exists(path):
220
- logger.info(f"Found MCP config: {path}")
221
- try:
222
- with open(path, 'r') as f:
223
- config = json.load(f)
224
-
225
- servers = config.get('mcpServers', {})
226
-
227
- # Load all servers except ourselves
228
- for name, cfg in servers.items():
229
- if name == 'langprotect-gateway' or name == 'langprotect':
230
- continue
231
-
232
- try:
233
- server = MCPServer(name, cfg)
234
- self.mcp_servers[name] = server
235
-
236
- # Discover tools from this server
237
- tools = server.discover_tools()
238
- for tool in tools:
239
- tool_name = tool.get('name')
240
- if tool_name:
241
- self.tool_to_server[tool_name] = name
242
- logger.debug(f" Tool: {tool_name} -> {name}")
243
-
244
- except Exception as e:
245
- logger.warning(f"Failed to load MCP server {name}: {e}")
246
-
247
- logger.info(f"✅ Loaded {len(self.mcp_servers)} MCP servers")
248
- logger.info(f"✅ Discovered {len(self.tool_to_server)} tools")
249
- return
250
-
251
- except Exception as e:
252
- logger.error(f"Error reading config {path}: {e}")
253
-
254
- logger.warning("⚠️ No MCP config found - gateway will not proxy any servers")
255
-
256
- def log_to_backend(self, method: str, params: Dict, server_name: str = "unknown") -> Dict:
257
- """Log MCP request to LangProtect backend for scanning"""
258
- self.ensure_token()
259
-
260
- try:
261
- # Build content for scanning - convert MCP request to scannable format
262
- if method == 'tools/call':
263
- tool_name = params.get('name', 'unknown_tool')
264
- arguments = params.get('arguments', {})
265
- scan_content = json.dumps({
266
- 'tool': tool_name,
267
- 'arguments': arguments,
268
- 'server_url': server_name,
269
- 'method': method
270
- })
271
- else:
272
- scan_content = json.dumps({
273
- 'method': method,
274
- 'params': params,
275
- 'server_url': server_name
276
- })
277
-
278
- # Use standard group scan endpoint (works with existing scanners + policies)
279
- payload = {
280
- 'prompt': scan_content, # MCP request as prompt
281
- 'client_ip': '127.0.0.1',
282
- 'user_agent': f'LangProtect-MCP-Gateway/1.0 (server={server_name})',
283
- 'source': 'mcp-gateway'
284
- }
285
-
286
- logger.debug(f"Logging to backend: {method} on {server_name}")
287
-
288
- response = requests.post(
289
- f"{self.langprotect_url}/v1/group-logs/scan",
290
- json=payload,
291
- headers={
292
- 'Authorization': f'Bearer {self.jwt_token}',
293
- 'Content-Type': 'application/json'
294
- },
295
- timeout=30
296
- )
297
-
298
- if response.status_code != 200:
299
- logger.error(f"Backend scan failed: {response.status_code} - {response.text}")
300
- # Fail-open: allow request if backend error
301
- return {'status': 'allowed', 'error': f'Backend error: {response.status_code}'}
302
-
303
- result = response.json()
304
- status = result.get('status', '').lower()
305
-
306
- logger.debug(f"Backend response: status={status}, log_id={result.get('id')}")
307
-
308
- return result
309
-
310
- except Exception as e:
311
- logger.error(f"Error logging to backend: {e}")
312
- # Fail-open for now (TODO: make configurable)
313
- return {'status': 'allowed', 'error': str(e)}
314
-
315
- def handle_mcp_request(self, request: Dict) -> Dict:
316
- """Main MCP protocol handler"""
317
- method = request.get('method')
318
- request_id = request.get('id')
319
-
320
- logger.info(f"📥 MCP Request: {method} (id={request_id})")
321
-
322
- try:
323
- if method == 'initialize':
324
- return self.handle_initialize(request)
325
- elif method == 'tools/list':
326
- return self.handle_list_tools(request)
327
- elif method == 'tools/call':
328
- return self.handle_call_tool(request)
329
- else:
330
- logger.warning(f"Unknown method: {method}")
331
- return {
332
- 'jsonrpc': '2.0',
333
- 'id': request_id,
334
- 'error': {
335
- 'code': -32601,
336
- 'message': f'Method not found: {method}'
337
- }
338
- }
339
-
340
- except Exception as e:
341
- logger.error(f"Error handling {method}: {e}", exc_info=True)
342
- return {
343
- 'jsonrpc': '2.0',
344
- 'id': request_id,
345
- 'error': {
346
- 'code': -32603,
347
- 'message': f'Internal error: {str(e)}'
348
- }
349
- }
350
-
351
- def handle_initialize(self, request: Dict) -> Dict:
352
- """Handle MCP initialize handshake"""
353
- logger.info("Handling initialize request")
354
-
355
- return {
356
- 'jsonrpc': '2.0',
357
- 'id': request.get('id'),
358
- 'result': {
359
- 'protocolVersion': '2024-11-05',
360
- 'capabilities': {
361
- 'tools': {}
362
- },
363
- 'serverInfo': {
364
- 'name': 'langprotect-gateway',
365
- 'version': '1.0.0'
366
- }
367
- }
368
- }
369
-
370
- def handle_list_tools(self, request: Dict) -> Dict:
371
- """Return all tools from all MCP servers"""
372
- logger.info(f"Listing tools from {len(self.mcp_servers)} servers")
373
-
374
- all_tools = []
375
-
376
- for server_name, server in self.mcp_servers.items():
377
- # Use cached tools from discovery
378
- for tool in server.tools:
379
- # Add server name to tool description for clarity
380
- tool_copy = tool.copy()
381
- original_desc = tool_copy.get('description', '')
382
- tool_copy['description'] = f"[{server_name}] {original_desc}"
383
- all_tools.append(tool_copy)
384
-
385
- logger.info(f"✅ Returning {len(all_tools)} tools")
386
-
387
- return {
388
- 'jsonrpc': '2.0',
389
- 'id': request.get('id'),
390
- 'result': {
391
- 'tools': all_tools
392
- }
393
- }
394
-
395
- def handle_call_tool(self, request: Dict) -> Dict:
396
- """Intercept tool call, log it, check policy, forward to server"""
397
- params = request.get('params', {})
398
- tool_name = params.get('name')
399
- arguments = params.get('arguments', {})
400
-
401
- logger.info(f"🔧 Tool call: {tool_name}")
402
- logger.debug(f" Arguments: {json.dumps(arguments)[:200]}")
403
-
404
- # Find which server owns this tool
405
- server_name = self.tool_to_server.get(tool_name)
406
- if not server_name:
407
- logger.error(f"Unknown tool: {tool_name}")
408
- return {
409
- 'jsonrpc': '2.0',
410
- 'id': request.get('id'),
411
- 'error': {
412
- 'code': -32602,
413
- 'message': f'Unknown tool: {tool_name}'
414
- }
415
- }
416
-
417
- # 1. Log to LangProtect backend (security scan)
418
- log_result = self.log_to_backend('tools/call', params, server_name)
419
-
420
- # 2. Check if blocked by policy
421
- status = log_result.get('status', '').lower()
422
- if status == 'blocked':
423
- reason = 'Policy violation'
424
- detections = log_result.get('detections', {})
425
- if 'MCPActionControl' in detections:
426
- reason = detections['MCPActionControl'].get('reason', reason)
427
-
428
- logger.warning(f"🛡️ BLOCKED: {tool_name} - {reason}")
429
-
430
- return {
431
- 'jsonrpc': '2.0',
432
- 'id': request.get('id'),
433
- 'error': {
434
- 'code': -32000,
435
- 'message': f'🛡️ LangProtect: {reason}',
436
- 'data': {
437
- 'log_id': log_result.get('id'),
438
- 'risk_score': log_result.get('risk_score'),
439
- 'detections': detections
440
- }
441
- }
442
- }
443
-
444
- # 3. Forward to actual MCP server
445
- logger.info(f"✅ ALLOWED: Forwarding to {server_name}")
446
-
447
- try:
448
- server = self.mcp_servers[server_name]
449
- response = server.call('tools/call', params)
450
-
451
- # Log successful response
452
- if 'result' in response:
453
- logger.info(f"✅ Tool {tool_name} completed successfully")
454
- elif 'error' in response:
455
- logger.warning(f"⚠️ Tool {tool_name} returned error: {response['error'].get('message')}")
456
-
457
- # Return response with original request ID
458
- response['id'] = request.get('id')
459
- return response
460
-
461
- except Exception as e:
462
- logger.error(f"Error executing {tool_name} on {server_name}: {e}")
463
- return {
464
- 'jsonrpc': '2.0',
465
- 'id': request.get('id'),
466
- 'error': {
467
- 'code': -32603,
468
- 'message': f'Error executing tool: {str(e)}'
469
- }
470
- }
471
-
472
- def run(self):
473
- """Main loop: read MCP requests from stdin, write responses to stdout"""
474
- logger.info("🚀 LangProtect Gateway started")
475
- logger.info(f"📡 Connected to: {self.langprotect_url}")
476
- logger.info(f"👤 User: {self.email}")
477
- logger.info(f"🔧 Proxying {len(self.mcp_servers)} MCP servers")
478
- logger.info("=" * 60)
479
-
480
- try:
481
- for line in sys.stdin:
482
- line = line.strip()
483
- if not line:
484
- continue
485
-
486
- try:
487
- request = json.loads(line)
488
- response = self.handle_mcp_request(request)
489
-
490
- # Write response to stdout (MCP protocol)
491
- print(json.dumps(response), flush=True)
492
-
493
- except json.JSONDecodeError as e:
494
- logger.error(f"Invalid JSON: {e}")
495
- error_response = {
496
- 'jsonrpc': '2.0',
497
- 'error': {
498
- 'code': -32700,
499
- 'message': f'Parse error: {str(e)}'
500
- }
501
- }
502
- print(json.dumps(error_response), flush=True)
503
-
504
- except KeyboardInterrupt:
505
- logger.info("\n👋 Gateway shutting down...")
506
- except Exception as e:
507
- logger.error(f"Fatal error: {e}", exc_info=True)
508
- sys.exit(1)
509
-
510
-
511
- def main():
512
- """Entry point"""
513
- # Check for setup mode
514
- if len(sys.argv) > 1 and sys.argv[1] == 'setup':
515
- logger.info("⚠️ Setup mode not yet implemented")
516
- logger.info("Please set environment variables:")
517
- logger.info(" LANGPROTECT_URL=https://api.langprotect.com")
518
- logger.info(" LANGPROTECT_EMAIL=your@email.com")
519
- logger.info(" LANGPROTECT_PASSWORD=yourpassword")
520
- sys.exit(1)
521
-
522
- # Start gateway
523
- gateway = LangProtectGateway()
524
- gateway.run()
525
-
526
-
527
- if __name__ == '__main__':
528
- main()