langprotect-mcp-gateway 1.0.0__py3-none-any.whl → 1.1.0__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.
- langprotect_mcp_gateway/__init__.py +1 -1
- langprotect_mcp_gateway/gateway.py +214 -421
- {langprotect_mcp_gateway-1.0.0.dist-info → langprotect_mcp_gateway-1.1.0.dist-info}/METADATA +1 -1
- langprotect_mcp_gateway-1.1.0.dist-info/RECORD +8 -0
- langprotect_mcp_gateway-1.0.0.dist-info/RECORD +0 -8
- {langprotect_mcp_gateway-1.0.0.dist-info → langprotect_mcp_gateway-1.1.0.dist-info}/WHEEL +0 -0
- {langprotect_mcp_gateway-1.0.0.dist-info → langprotect_mcp_gateway-1.1.0.dist-info}/entry_points.txt +0 -0
- {langprotect_mcp_gateway-1.0.0.dist-info → langprotect_mcp_gateway-1.1.0.dist-info}/licenses/LICENSE +0 -0
- {langprotect_mcp_gateway-1.0.0.dist-info → langprotect_mcp_gateway-1.1.0.dist-info}/top_level.txt +0 -0
|
@@ -1,26 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""
|
|
3
|
-
LangProtect MCP Gateway -
|
|
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)
|
|
3
|
+
LangProtect MCP Gateway - Security Gateway for MCP Servers
|
|
24
4
|
"""
|
|
25
5
|
|
|
26
6
|
import sys
|
|
@@ -28,86 +8,82 @@ import json
|
|
|
28
8
|
import os
|
|
29
9
|
import subprocess
|
|
30
10
|
import requests
|
|
31
|
-
import
|
|
32
|
-
import time
|
|
11
|
+
import argparse
|
|
33
12
|
from datetime import datetime, timedelta
|
|
34
|
-
from pathlib import Path
|
|
35
13
|
from typing import Dict, List, Any, Optional
|
|
36
14
|
import logging
|
|
37
15
|
|
|
38
|
-
|
|
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
|
-
)
|
|
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)])
|
|
44
18
|
logger = logging.getLogger('langprotect-gateway')
|
|
45
19
|
|
|
46
20
|
|
|
47
21
|
class MCPServer:
|
|
48
|
-
"""Represents a single MCP server that can be called"""
|
|
49
|
-
|
|
50
22
|
def __init__(self, name: str, config: Dict[str, Any]):
|
|
51
23
|
self.name = name
|
|
52
24
|
self.command = config.get('command')
|
|
53
25
|
self.args = config.get('args', [])
|
|
54
26
|
self.env = config.get('env', {})
|
|
55
|
-
self.tools = []
|
|
56
|
-
self.process = None
|
|
57
|
-
|
|
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)})")
|
|
58
31
|
|
|
59
|
-
def
|
|
60
|
-
|
|
32
|
+
def _next_id(self) -> int:
|
|
33
|
+
self._request_id += 1
|
|
34
|
+
return self._request_id
|
|
35
|
+
|
|
36
|
+
def start(self) -> bool:
|
|
61
37
|
try:
|
|
62
|
-
# Start process for this call
|
|
63
38
|
env = {**os.environ, **self.env}
|
|
64
|
-
|
|
65
|
-
self.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
)
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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:
|
|
84
74
|
self.process.stdin.write(json.dumps(request) + "\n")
|
|
85
75
|
self.process.stdin.flush()
|
|
86
|
-
|
|
87
|
-
# Read response
|
|
88
76
|
response_line = self.process.stdout.readline()
|
|
89
77
|
if not response_line:
|
|
90
|
-
|
|
91
|
-
raise Exception(f"No response from {self.name}: {error_output}")
|
|
92
|
-
|
|
78
|
+
raise Exception(f"No response from {self.name}")
|
|
93
79
|
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
|
-
|
|
80
|
+
logger.debug(f"<- {self.name}: {str(response)[:200]}")
|
|
101
81
|
return response
|
|
102
|
-
|
|
103
82
|
except Exception as e:
|
|
104
|
-
logger.error(f"Error calling {self.name}: {e}")
|
|
105
|
-
if self.process:
|
|
106
|
-
self.process.terminate()
|
|
83
|
+
logger.error(f"Error calling {self.name}.{method}: {e}")
|
|
107
84
|
raise
|
|
108
85
|
|
|
109
86
|
def discover_tools(self) -> List[Dict]:
|
|
110
|
-
"""Discover what tools this MCP server provides"""
|
|
111
87
|
try:
|
|
112
88
|
response = self.call("tools/list", {})
|
|
113
89
|
if "result" in response:
|
|
@@ -120,407 +96,224 @@ class MCPServer:
|
|
|
120
96
|
return []
|
|
121
97
|
|
|
122
98
|
|
|
123
|
-
class
|
|
124
|
-
|
|
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
|
|
125
106
|
|
|
126
|
-
def
|
|
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"""
|
|
107
|
+
def login(self) -> bool:
|
|
159
108
|
try:
|
|
160
|
-
logger.info(f"
|
|
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
|
-
|
|
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)
|
|
171
111
|
if response.status_code == 200:
|
|
172
112
|
data = response.json()
|
|
173
113
|
self.jwt_token = data.get('access_token')
|
|
174
114
|
self.token_expiry = datetime.now() + timedelta(days=6)
|
|
175
|
-
logger.info("
|
|
115
|
+
logger.info("Authentication successful")
|
|
176
116
|
return True
|
|
177
117
|
else:
|
|
178
|
-
logger.error(f"
|
|
179
|
-
|
|
180
|
-
|
|
118
|
+
logger.error(f"Login failed: {response.status_code}")
|
|
119
|
+
return False
|
|
181
120
|
except Exception as e:
|
|
182
|
-
logger.error(f"
|
|
183
|
-
|
|
121
|
+
logger.error(f"Login error: {e}")
|
|
122
|
+
return False
|
|
184
123
|
|
|
185
|
-
def ensure_token(self):
|
|
186
|
-
"""Ensure we have a valid JWT token"""
|
|
124
|
+
def ensure_token(self) -> bool:
|
|
187
125
|
if not self.jwt_token or (self.token_expiry and datetime.now() > self.token_expiry):
|
|
188
|
-
logger.info("Token expired, re-authenticating...")
|
|
189
126
|
return self.login()
|
|
190
127
|
return True
|
|
191
128
|
|
|
192
|
-
def
|
|
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"""
|
|
129
|
+
def scan(self, tool_name: str, arguments: Dict, server_name: str) -> Dict:
|
|
258
130
|
self.ensure_token()
|
|
259
|
-
|
|
260
131
|
try:
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
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)
|
|
298
134
|
if response.status_code != 200:
|
|
299
|
-
logger.
|
|
300
|
-
# Fail-open: allow request if backend error
|
|
135
|
+
logger.warning(f"Backend returned {response.status_code}, allowing request (fail-open)")
|
|
301
136
|
return {'status': 'allowed', 'error': f'Backend error: {response.status_code}'}
|
|
302
|
-
|
|
303
137
|
result = response.json()
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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'}
|
|
308
142
|
return result
|
|
309
|
-
|
|
143
|
+
except requests.exceptions.Timeout:
|
|
144
|
+
logger.warning("Backend scan timeout, allowing request (fail-open)")
|
|
145
|
+
return {'status': 'allowed', 'error': 'Request timeout'}
|
|
310
146
|
except Exception as e:
|
|
311
|
-
logger.error(f"
|
|
312
|
-
# Fail-open for now (TODO: make configurable)
|
|
147
|
+
logger.error(f"Scan error: {e}")
|
|
313
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
|
|
314
183
|
|
|
315
|
-
def
|
|
316
|
-
|
|
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]:
|
|
317
235
|
method = request.get('method')
|
|
318
236
|
request_id = request.get('id')
|
|
319
|
-
|
|
320
|
-
logger.info(f"
|
|
321
|
-
|
|
237
|
+
params = request.get('params', {})
|
|
238
|
+
logger.info(f"Request: {method} (id={request_id})")
|
|
322
239
|
try:
|
|
323
240
|
if method == 'initialize':
|
|
324
|
-
return
|
|
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
|
|
325
244
|
elif method == 'tools/list':
|
|
326
|
-
|
|
245
|
+
logger.info(f"Returning {len(self.all_tools)} tools")
|
|
246
|
+
return {'jsonrpc': '2.0', 'id': request_id, 'result': {'tools': self.all_tools}}
|
|
327
247
|
elif method == 'tools/call':
|
|
328
|
-
return self.
|
|
248
|
+
return self._handle_call_tool(request_id, params)
|
|
329
249
|
else:
|
|
330
|
-
|
|
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
|
-
|
|
250
|
+
return {'jsonrpc': '2.0', 'id': request_id, 'error': {'code': -32601, 'message': f'Method not found: {method}'}}
|
|
340
251
|
except Exception as e:
|
|
341
|
-
logger.error(f"Error handling {method}: {e}"
|
|
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
|
-
}
|
|
252
|
+
logger.error(f"Error handling {method}: {e}")
|
|
253
|
+
return {'jsonrpc': '2.0', 'id': request_id, 'error': {'code': -32603, 'message': str(e)}}
|
|
369
254
|
|
|
370
|
-
def
|
|
371
|
-
|
|
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')
|
|
255
|
+
def _handle_call_tool(self, request_id, params: Dict) -> Dict:
|
|
256
|
+
tool_name = params.get('name', '')
|
|
399
257
|
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
258
|
server_name = self.tool_to_server.get(tool_name)
|
|
406
259
|
if not server_name:
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
|
|
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')})")
|
|
447
273
|
try:
|
|
448
|
-
|
|
449
|
-
response = server.call('tools/call', params)
|
|
450
|
-
|
|
451
|
-
# Log successful response
|
|
274
|
+
response = server.call('tools/call', {'name': tool_name, 'arguments': arguments})
|
|
452
275
|
if 'result' in response:
|
|
453
|
-
|
|
276
|
+
return {'jsonrpc': '2.0', 'id': request_id, 'result': response['result']}
|
|
454
277
|
elif 'error' in response:
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
# Return response with original request ID
|
|
458
|
-
response['id'] = request.get('id')
|
|
278
|
+
return {'jsonrpc': '2.0', 'id': request_id, 'error': response['error']}
|
|
459
279
|
return response
|
|
460
|
-
|
|
461
280
|
except Exception as e:
|
|
462
|
-
|
|
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
|
-
}
|
|
281
|
+
return {'jsonrpc': '2.0', 'id': request_id, 'error': {'code': -32603, 'message': f'Error executing tool: {e}'}}
|
|
471
282
|
|
|
472
283
|
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
284
|
try:
|
|
481
285
|
for line in sys.stdin:
|
|
482
286
|
line = line.strip()
|
|
483
287
|
if not line:
|
|
484
288
|
continue
|
|
485
|
-
|
|
486
289
|
try:
|
|
487
290
|
request = json.loads(line)
|
|
488
|
-
response = self.
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
print(json.dumps(response), flush=True)
|
|
492
|
-
|
|
291
|
+
response = self.handle_request(request)
|
|
292
|
+
if response:
|
|
293
|
+
print(json.dumps(response), flush=True)
|
|
493
294
|
except json.JSONDecodeError as e:
|
|
494
|
-
|
|
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
|
-
|
|
295
|
+
print(json.dumps({'jsonrpc': '2.0', 'error': {'code': -32700, 'message': f'Parse error: {e}'}}), flush=True)
|
|
504
296
|
except KeyboardInterrupt:
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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()
|
|
509
307
|
|
|
510
308
|
|
|
511
309
|
def main():
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
logger.info(" LANGPROTECT_EMAIL=your@email.com")
|
|
519
|
-
logger.info(" LANGPROTECT_PASSWORD=yourpassword")
|
|
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():
|
|
520
316
|
sys.exit(1)
|
|
521
|
-
|
|
522
|
-
# Start gateway
|
|
523
|
-
gateway = LangProtectGateway()
|
|
524
317
|
gateway.run()
|
|
525
318
|
|
|
526
319
|
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
langprotect_mcp_gateway/__init__.py,sha256=tRGe-nBw57h5EtB27h7RrUPcZTzuLylVzl8-REZzzDU,510
|
|
2
|
+
langprotect_mcp_gateway/gateway.py,sha256=Xw88Zo7EXDPyTjX0FJ-txeFqQLlCHmG2lwdSBiYRoIg,14727
|
|
3
|
+
langprotect_mcp_gateway-1.1.0.dist-info/licenses/LICENSE,sha256=aoVP65gKtirVmFPToow5L9IKN4FNjfM6Sejq_5b4cbM,1082
|
|
4
|
+
langprotect_mcp_gateway-1.1.0.dist-info/METADATA,sha256=XtdiT6OJnC_U-6bZ53NoNug3F21FJgRW_IZd7oQopJk,6152
|
|
5
|
+
langprotect_mcp_gateway-1.1.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
6
|
+
langprotect_mcp_gateway-1.1.0.dist-info/entry_points.txt,sha256=iM5-7ReYo6_nFF-2DHK1cSi1Nj6wGsG4QqJgcNZ7_GE,69
|
|
7
|
+
langprotect_mcp_gateway-1.1.0.dist-info/top_level.txt,sha256=UjNlX13ma4nwJXuEyi9eMX251c5rooeEao4zajX6ZHk,24
|
|
8
|
+
langprotect_mcp_gateway-1.1.0.dist-info/RECORD,,
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
langprotect_mcp_gateway/__init__.py,sha256=RAVgb8Z_PR2GkmgpAok9G1LCtcSsOarmb01j1pO6asc,510
|
|
2
|
-
langprotect_mcp_gateway/gateway.py,sha256=72D-rGfOU-BRzSGdEnCjVGNP1OoSr9wBCFx7Kjzq1_c,19901
|
|
3
|
-
langprotect_mcp_gateway-1.0.0.dist-info/licenses/LICENSE,sha256=aoVP65gKtirVmFPToow5L9IKN4FNjfM6Sejq_5b4cbM,1082
|
|
4
|
-
langprotect_mcp_gateway-1.0.0.dist-info/METADATA,sha256=s97MVu9Zj70QhxXH-sszyxyvSeYkXdc_ICa9cN4xyw4,6152
|
|
5
|
-
langprotect_mcp_gateway-1.0.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
6
|
-
langprotect_mcp_gateway-1.0.0.dist-info/entry_points.txt,sha256=iM5-7ReYo6_nFF-2DHK1cSi1Nj6wGsG4QqJgcNZ7_GE,69
|
|
7
|
-
langprotect_mcp_gateway-1.0.0.dist-info/top_level.txt,sha256=UjNlX13ma4nwJXuEyi9eMX251c5rooeEao4zajX6ZHk,24
|
|
8
|
-
langprotect_mcp_gateway-1.0.0.dist-info/RECORD,,
|
|
File without changes
|
{langprotect_mcp_gateway-1.0.0.dist-info → langprotect_mcp_gateway-1.1.0.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{langprotect_mcp_gateway-1.0.0.dist-info → langprotect_mcp_gateway-1.1.0.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
{langprotect_mcp_gateway-1.0.0.dist-info → langprotect_mcp_gateway-1.1.0.dist-info}/top_level.txt
RENAMED
|
File without changes
|