langprotect-mcp-gateway 1.0.0__py3-none-any.whl → 1.2.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 +270 -415
- langprotect_mcp_gateway-1.2.0.dist-info/METADATA +333 -0
- langprotect_mcp_gateway-1.2.0.dist-info/RECORD +8 -0
- langprotect_mcp_gateway-1.0.0.dist-info/METADATA +0 -215
- langprotect_mcp_gateway-1.0.0.dist-info/RECORD +0 -8
- {langprotect_mcp_gateway-1.0.0.dist-info → langprotect_mcp_gateway-1.2.0.dist-info}/WHEEL +0 -0
- {langprotect_mcp_gateway-1.0.0.dist-info → langprotect_mcp_gateway-1.2.0.dist-info}/entry_points.txt +0 -0
- {langprotect_mcp_gateway-1.0.0.dist-info → langprotect_mcp_gateway-1.2.0.dist-info}/licenses/LICENSE +0 -0
- {langprotect_mcp_gateway-1.0.0.dist-info → langprotect_mcp_gateway-1.2.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,286 @@ class MCPServer:
|
|
|
120
96
|
return []
|
|
121
97
|
|
|
122
98
|
|
|
123
|
-
class
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
self.
|
|
128
|
-
self.
|
|
129
|
-
self.
|
|
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")
|
|
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
|
|
156
106
|
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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.mcp_json_path = mcp_json_path
|
|
200
154
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
]
|
|
155
|
+
# Load credentials from env vars first, then potentially from config
|
|
156
|
+
self.langprotect_url = os.getenv('LANGPROTECT_URL', 'http://localhost:8000')
|
|
157
|
+
self.email = os.getenv('LANGPROTECT_EMAIL')
|
|
158
|
+
self.password = os.getenv('LANGPROTECT_PASSWORD')
|
|
213
159
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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}")
|
|
160
|
+
# Try to load credentials from mcp.json env section (like Lasso)
|
|
161
|
+
if mcp_json_path and (not self.email or not self.password):
|
|
162
|
+
self._load_env_from_config(mcp_json_path)
|
|
253
163
|
|
|
254
|
-
|
|
164
|
+
self.auth: Optional[LangProtectAuth] = None
|
|
165
|
+
self.mcp_servers: Dict[str, MCPServer] = {}
|
|
166
|
+
self.tool_to_server: Dict[str, str] = {}
|
|
167
|
+
self.all_tools: List[Dict] = []
|
|
168
|
+
logger.debug(f"LANGPROTECT_URL: {self.langprotect_url}")
|
|
169
|
+
logger.debug(f"LANGPROTECT_EMAIL: {self.email}")
|
|
255
170
|
|
|
256
|
-
def
|
|
257
|
-
"""
|
|
258
|
-
self.ensure_token()
|
|
259
|
-
|
|
171
|
+
def _load_env_from_config(self, path: str):
|
|
172
|
+
"""Load credentials from mcp.json env section (Lasso-style)"""
|
|
260
173
|
try:
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
})
|
|
174
|
+
expanded_path = os.path.expanduser(path)
|
|
175
|
+
with open(expanded_path, 'r') as f:
|
|
176
|
+
config = json.load(f)
|
|
277
177
|
|
|
278
|
-
#
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
'
|
|
283
|
-
|
|
284
|
-
|
|
178
|
+
# Look for env vars in the gateway's config section
|
|
179
|
+
mcp_servers = config.get('mcpServers', {})
|
|
180
|
+
for gateway_name in ['langprotect-gateway', 'langprotect', 'mcp-gateway']:
|
|
181
|
+
gateway_config = mcp_servers.get(gateway_name, {})
|
|
182
|
+
env_section = gateway_config.get('env', {})
|
|
183
|
+
if env_section:
|
|
184
|
+
if not self.langprotect_url or self.langprotect_url == 'http://localhost:8000':
|
|
185
|
+
self.langprotect_url = env_section.get('LANGPROTECT_URL', self.langprotect_url)
|
|
186
|
+
if not self.email:
|
|
187
|
+
self.email = env_section.get('LANGPROTECT_EMAIL')
|
|
188
|
+
if not self.password:
|
|
189
|
+
self.password = env_section.get('LANGPROTECT_PASSWORD')
|
|
190
|
+
logger.info(f"Loaded credentials from config env section")
|
|
191
|
+
break
|
|
192
|
+
except Exception as e:
|
|
193
|
+
logger.debug(f"Could not load env from config: {e}")
|
|
194
|
+
|
|
195
|
+
def initialize(self) -> bool:
|
|
196
|
+
if self.email and self.password:
|
|
197
|
+
self.auth = LangProtectAuth(self.langprotect_url, self.email, self.password)
|
|
198
|
+
if not self.auth.login():
|
|
199
|
+
logger.error("Failed to authenticate with LangProtect backend")
|
|
200
|
+
return False
|
|
201
|
+
else:
|
|
202
|
+
logger.warning("No LangProtect credentials - running in pass-through mode")
|
|
203
|
+
if not self.load_servers():
|
|
204
|
+
return False
|
|
205
|
+
if not self.start_servers():
|
|
206
|
+
return False
|
|
207
|
+
logger.info("=" * 50)
|
|
208
|
+
logger.info("LangProtect Gateway initialized")
|
|
209
|
+
logger.info(f"Backend: {self.langprotect_url}")
|
|
210
|
+
logger.info(f"Servers: {len(self.mcp_servers)}")
|
|
211
|
+
logger.info(f"Tools: {len(self.all_tools)}")
|
|
212
|
+
logger.info("=" * 50)
|
|
213
|
+
return True
|
|
214
|
+
|
|
215
|
+
def load_servers(self) -> bool:
|
|
216
|
+
# Mode 1: Single server via environment variables (for wrapper scripts)
|
|
217
|
+
mcp_command = os.getenv('MCP_SERVER_COMMAND')
|
|
218
|
+
mcp_args = os.getenv('MCP_SERVER_ARGS')
|
|
219
|
+
if mcp_command:
|
|
220
|
+
logger.info(f"Single server mode: {mcp_command}")
|
|
221
|
+
args_list = [arg.strip() for arg in mcp_args.split(',')] if mcp_args else []
|
|
222
|
+
server_name = os.getenv('MCP_SERVER_NAME', 'proxied-server')
|
|
223
|
+
self.mcp_servers[server_name] = MCPServer(server_name, {'command': mcp_command, 'args': args_list, 'env': {}})
|
|
224
|
+
return True
|
|
225
|
+
|
|
226
|
+
# Mode 2: Config file (mcp.json)
|
|
227
|
+
if self.mcp_json_path:
|
|
228
|
+
return self.load_from_mcp_json(self.mcp_json_path)
|
|
229
|
+
|
|
230
|
+
logger.warning("No MCP servers configured")
|
|
231
|
+
return False
|
|
232
|
+
|
|
233
|
+
def load_from_mcp_json(self, path: str) -> bool:
|
|
234
|
+
try:
|
|
235
|
+
expanded_path = os.path.expanduser(path)
|
|
236
|
+
with open(expanded_path, 'r') as f:
|
|
237
|
+
config = json.load(f)
|
|
285
238
|
|
|
286
|
-
|
|
239
|
+
# Try multiple config structures:
|
|
240
|
+
# 1. Lasso-style: mcpServers.langprotect-gateway.servers (nested)
|
|
241
|
+
# 2. VS Code style: servers (direct)
|
|
242
|
+
# 3. Claude Desktop style: mcpServers (direct)
|
|
287
243
|
|
|
288
|
-
|
|
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
|
-
)
|
|
244
|
+
servers = {}
|
|
297
245
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
246
|
+
# Check for Lasso-style nested config
|
|
247
|
+
mcp_servers = config.get('mcpServers', {})
|
|
248
|
+
for gateway_name in ['langprotect-gateway', 'langprotect', 'mcp-gateway']:
|
|
249
|
+
gateway_config = mcp_servers.get(gateway_name, {})
|
|
250
|
+
if 'servers' in gateway_config:
|
|
251
|
+
servers = gateway_config['servers']
|
|
252
|
+
logger.info(f"Found nested servers config under mcpServers.{gateway_name}.servers")
|
|
253
|
+
break
|
|
302
254
|
|
|
303
|
-
|
|
304
|
-
|
|
255
|
+
# Fallback to direct config
|
|
256
|
+
if not servers:
|
|
257
|
+
servers = config.get('servers', config.get('mcpServers', {}))
|
|
305
258
|
|
|
306
|
-
|
|
259
|
+
if not servers:
|
|
260
|
+
logger.error("No servers found in config file")
|
|
261
|
+
return False
|
|
307
262
|
|
|
308
|
-
|
|
263
|
+
for name, cfg in servers.items():
|
|
264
|
+
# Skip gateway self-references
|
|
265
|
+
if name in ['langprotect-gateway', 'langprotect', 'mcp-gateway']:
|
|
266
|
+
continue
|
|
267
|
+
self.mcp_servers[name] = MCPServer(name, cfg)
|
|
309
268
|
|
|
269
|
+
logger.info(f"Loaded {len(self.mcp_servers)} servers from config")
|
|
270
|
+
return len(self.mcp_servers) > 0
|
|
310
271
|
except Exception as e:
|
|
311
|
-
logger.error(f"Error
|
|
312
|
-
|
|
313
|
-
|
|
272
|
+
logger.error(f"Error loading {path}: {e}")
|
|
273
|
+
return False
|
|
274
|
+
|
|
275
|
+
def start_servers(self) -> bool:
|
|
276
|
+
started = 0
|
|
277
|
+
for name, server in list(self.mcp_servers.items()):
|
|
278
|
+
if server.start():
|
|
279
|
+
tools = server.discover_tools()
|
|
280
|
+
for tool in tools:
|
|
281
|
+
tool_name = tool.get('name')
|
|
282
|
+
if tool_name:
|
|
283
|
+
self.tool_to_server[tool_name] = name
|
|
284
|
+
tool_copy = tool.copy()
|
|
285
|
+
tool_copy['description'] = f"[{name}] {tool_copy.get('description', '')}"
|
|
286
|
+
self.all_tools.append(tool_copy)
|
|
287
|
+
started += 1
|
|
288
|
+
else:
|
|
289
|
+
del self.mcp_servers[name]
|
|
290
|
+
return started > 0
|
|
314
291
|
|
|
315
|
-
def
|
|
316
|
-
|
|
292
|
+
def shutdown(self):
|
|
293
|
+
for server in self.mcp_servers.values():
|
|
294
|
+
server.stop()
|
|
295
|
+
|
|
296
|
+
def handle_request(self, request: Dict) -> Optional[Dict]:
|
|
317
297
|
method = request.get('method')
|
|
318
298
|
request_id = request.get('id')
|
|
319
|
-
|
|
320
|
-
logger.info(f"
|
|
321
|
-
|
|
299
|
+
params = request.get('params', {})
|
|
300
|
+
logger.info(f"Request: {method} (id={request_id})")
|
|
322
301
|
try:
|
|
323
302
|
if method == 'initialize':
|
|
324
|
-
return
|
|
303
|
+
return {'jsonrpc': '2.0', 'id': request_id, 'result': {'protocolVersion': '2024-11-05', 'capabilities': {'tools': {}}, 'serverInfo': {'name': 'langprotect-gateway', 'version': '1.0.0'}}}
|
|
304
|
+
elif method == 'notifications/initialized':
|
|
305
|
+
return None
|
|
325
306
|
elif method == 'tools/list':
|
|
326
|
-
|
|
307
|
+
logger.info(f"Returning {len(self.all_tools)} tools")
|
|
308
|
+
return {'jsonrpc': '2.0', 'id': request_id, 'result': {'tools': self.all_tools}}
|
|
327
309
|
elif method == 'tools/call':
|
|
328
|
-
return self.
|
|
310
|
+
return self._handle_call_tool(request_id, params)
|
|
329
311
|
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
|
-
|
|
312
|
+
return {'jsonrpc': '2.0', 'id': request_id, 'error': {'code': -32601, 'message': f'Method not found: {method}'}}
|
|
340
313
|
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
|
-
}
|
|
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
|
-
}
|
|
314
|
+
logger.error(f"Error handling {method}: {e}")
|
|
315
|
+
return {'jsonrpc': '2.0', 'id': request_id, 'error': {'code': -32603, 'message': str(e)}}
|
|
394
316
|
|
|
395
|
-
def
|
|
396
|
-
|
|
397
|
-
params = request.get('params', {})
|
|
398
|
-
tool_name = params.get('name')
|
|
317
|
+
def _handle_call_tool(self, request_id, params: Dict) -> Dict:
|
|
318
|
+
tool_name = params.get('name', '')
|
|
399
319
|
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
320
|
server_name = self.tool_to_server.get(tool_name)
|
|
406
321
|
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
|
-
|
|
322
|
+
return {'jsonrpc': '2.0', 'id': request_id, 'error': {'code': -32602, 'message': f'Unknown tool: {tool_name}'}}
|
|
323
|
+
server = self.mcp_servers.get(server_name)
|
|
324
|
+
if not server:
|
|
325
|
+
return {'jsonrpc': '2.0', 'id': request_id, 'error': {'code': -32602, 'message': f'Server not found: {server_name}'}}
|
|
326
|
+
logger.info(f"Tool call: {server_name}.{tool_name}")
|
|
327
|
+
if self.auth:
|
|
328
|
+
scan_result = self.auth.scan(tool_name, arguments, server_name)
|
|
329
|
+
status = scan_result.get('status', '').lower()
|
|
330
|
+
if status == 'blocked':
|
|
331
|
+
reason = scan_result.get('detections', {}).get('MCPActionControl', {}).get('reason', 'Policy violation')
|
|
332
|
+
logger.warning(f"BLOCKED: {tool_name} - {reason}")
|
|
333
|
+
return {'jsonrpc': '2.0', 'id': request_id, 'error': {'code': -32000, 'message': f'LangProtect: {reason}'}}
|
|
334
|
+
logger.info(f"ALLOWED (log_id={scan_result.get('id')})")
|
|
447
335
|
try:
|
|
448
|
-
|
|
449
|
-
response = server.call('tools/call', params)
|
|
450
|
-
|
|
451
|
-
# Log successful response
|
|
336
|
+
response = server.call('tools/call', {'name': tool_name, 'arguments': arguments})
|
|
452
337
|
if 'result' in response:
|
|
453
|
-
|
|
338
|
+
return {'jsonrpc': '2.0', 'id': request_id, 'result': response['result']}
|
|
454
339
|
elif 'error' in response:
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
# Return response with original request ID
|
|
458
|
-
response['id'] = request.get('id')
|
|
340
|
+
return {'jsonrpc': '2.0', 'id': request_id, 'error': response['error']}
|
|
459
341
|
return response
|
|
460
|
-
|
|
461
342
|
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
|
-
}
|
|
343
|
+
return {'jsonrpc': '2.0', 'id': request_id, 'error': {'code': -32603, 'message': f'Error executing tool: {e}'}}
|
|
471
344
|
|
|
472
345
|
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
346
|
try:
|
|
481
347
|
for line in sys.stdin:
|
|
482
348
|
line = line.strip()
|
|
483
349
|
if not line:
|
|
484
350
|
continue
|
|
485
|
-
|
|
486
351
|
try:
|
|
487
352
|
request = json.loads(line)
|
|
488
|
-
response = self.
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
print(json.dumps(response), flush=True)
|
|
492
|
-
|
|
353
|
+
response = self.handle_request(request)
|
|
354
|
+
if response:
|
|
355
|
+
print(json.dumps(response), flush=True)
|
|
493
356
|
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
|
-
|
|
357
|
+
print(json.dumps({'jsonrpc': '2.0', 'error': {'code': -32700, 'message': f'Parse error: {e}'}}), flush=True)
|
|
504
358
|
except KeyboardInterrupt:
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
359
|
+
pass
|
|
360
|
+
finally:
|
|
361
|
+
self.shutdown()
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def parse_args():
|
|
365
|
+
parser = argparse.ArgumentParser(description='LangProtect MCP Gateway')
|
|
366
|
+
parser.add_argument('--mcp-json-path', type=str, help='Path to mcp.json configuration file')
|
|
367
|
+
parser.add_argument('--debug', action='store_true', help='Enable debug logging')
|
|
368
|
+
return parser.parse_args()
|
|
509
369
|
|
|
510
370
|
|
|
511
371
|
def main():
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
logger.info(" LANGPROTECT_EMAIL=your@email.com")
|
|
519
|
-
logger.info(" LANGPROTECT_PASSWORD=yourpassword")
|
|
372
|
+
args = parse_args()
|
|
373
|
+
if args.debug:
|
|
374
|
+
os.environ['DEBUG'] = 'true'
|
|
375
|
+
logging.getLogger('langprotect-gateway').setLevel(logging.DEBUG)
|
|
376
|
+
gateway = LangProtectGateway(mcp_json_path=args.mcp_json_path)
|
|
377
|
+
if not gateway.initialize():
|
|
520
378
|
sys.exit(1)
|
|
521
|
-
|
|
522
|
-
# Start gateway
|
|
523
|
-
gateway = LangProtectGateway()
|
|
524
379
|
gateway.run()
|
|
525
380
|
|
|
526
381
|
|