agent-mcp-gateway 0.2.1__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.
- agent_mcp_gateway-0.2.1.dist-info/METADATA +1330 -0
- agent_mcp_gateway-0.2.1.dist-info/RECORD +18 -0
- agent_mcp_gateway-0.2.1.dist-info/WHEEL +4 -0
- agent_mcp_gateway-0.2.1.dist-info/entry_points.txt +2 -0
- agent_mcp_gateway-0.2.1.dist-info/licenses/LICENSE +21 -0
- src/CONFIG_README.md +351 -0
- src/__init__.py +1 -0
- src/audit.py +94 -0
- src/config/.mcp-gateway-rules.json.example +59 -0
- src/config/.mcp.json.example +30 -0
- src/config.py +849 -0
- src/config_watcher.py +296 -0
- src/gateway.py +547 -0
- src/main.py +570 -0
- src/metrics.py +299 -0
- src/middleware.py +166 -0
- src/policy.py +500 -0
- src/proxy.py +649 -0
src/config.py
ADDED
|
@@ -0,0 +1,849 @@
|
|
|
1
|
+
"""Configuration management for Agent MCP Gateway."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# Set up logger
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
# Global variables to store config file paths for reloading
|
|
16
|
+
_mcp_config_path: Optional[str] = None
|
|
17
|
+
_gateway_rules_path: Optional[str] = None
|
|
18
|
+
|
|
19
|
+
# Store validation warnings from the last reload
|
|
20
|
+
_last_validation_warnings: list[str] = []
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def validate_mcp_config(config: dict) -> tuple[bool, Optional[str]]:
|
|
24
|
+
"""Validate MCP server configuration structure.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
config: Dictionary containing MCP server configuration
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Tuple of (is_valid, error_message). Returns (True, None) if valid,
|
|
31
|
+
(False, error_message) if invalid.
|
|
32
|
+
"""
|
|
33
|
+
# Validate top-level structure
|
|
34
|
+
if not isinstance(config, dict):
|
|
35
|
+
return False, f"MCP server configuration must be a JSON object, got {type(config).__name__}"
|
|
36
|
+
|
|
37
|
+
if "mcpServers" not in config:
|
|
38
|
+
return False, 'Missing required key "mcpServers"'
|
|
39
|
+
|
|
40
|
+
mcp_servers = config["mcpServers"]
|
|
41
|
+
if not isinstance(mcp_servers, dict):
|
|
42
|
+
return False, f'"mcpServers" must be an object, got {type(mcp_servers).__name__}'
|
|
43
|
+
|
|
44
|
+
# Validate each server configuration
|
|
45
|
+
for server_name, server_config in mcp_servers.items():
|
|
46
|
+
if not isinstance(server_config, dict):
|
|
47
|
+
return False, (
|
|
48
|
+
f'Server "{server_name}" configuration must be an object, '
|
|
49
|
+
f'got {type(server_config).__name__}'
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Determine transport type and validate required fields
|
|
53
|
+
has_command = "command" in server_config
|
|
54
|
+
has_url = "url" in server_config
|
|
55
|
+
|
|
56
|
+
if has_command and has_url:
|
|
57
|
+
return False, (
|
|
58
|
+
f'Server "{server_name}" cannot have both "command" (stdio) '
|
|
59
|
+
f'and "url" (HTTP) - specify one transport type only'
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
if not has_command and not has_url:
|
|
63
|
+
return False, (
|
|
64
|
+
f'Server "{server_name}" must specify either "command" (stdio) '
|
|
65
|
+
f'or "url" (HTTP) transport'
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Validate stdio transport
|
|
69
|
+
if has_command:
|
|
70
|
+
if not isinstance(server_config["command"], str):
|
|
71
|
+
return False, (
|
|
72
|
+
f'Server "{server_name}": "command" must be a string, '
|
|
73
|
+
f'got {type(server_config["command"]).__name__}'
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
if "args" in server_config:
|
|
77
|
+
if not isinstance(server_config["args"], list):
|
|
78
|
+
return False, (
|
|
79
|
+
f'Server "{server_name}": "args" must be an array, '
|
|
80
|
+
f'got {type(server_config["args"]).__name__}'
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
for i, arg in enumerate(server_config["args"]):
|
|
84
|
+
if not isinstance(arg, str):
|
|
85
|
+
return False, (
|
|
86
|
+
f'Server "{server_name}": args[{i}] must be a string, '
|
|
87
|
+
f'got {type(arg).__name__}'
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
if "env" in server_config:
|
|
91
|
+
if not isinstance(server_config["env"], dict):
|
|
92
|
+
return False, (
|
|
93
|
+
f'Server "{server_name}": "env" must be an object, '
|
|
94
|
+
f'got {type(server_config["env"]).__name__}'
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
for key, value in server_config["env"].items():
|
|
98
|
+
if not isinstance(value, str):
|
|
99
|
+
return False, (
|
|
100
|
+
f'Server "{server_name}": env["{key}"] must be a string, '
|
|
101
|
+
f'got {type(value).__name__}'
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# Validate HTTP transport
|
|
105
|
+
if has_url:
|
|
106
|
+
if not isinstance(server_config["url"], str):
|
|
107
|
+
return False, (
|
|
108
|
+
f'Server "{server_name}": "url" must be a string, '
|
|
109
|
+
f'got {type(server_config["url"]).__name__}'
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Basic URL validation
|
|
113
|
+
url = server_config["url"]
|
|
114
|
+
if not (url.startswith("http://") or url.startswith("https://")):
|
|
115
|
+
return False, (
|
|
116
|
+
f'Server "{server_name}": "url" must start with http:// or https://, '
|
|
117
|
+
f'got "{url}"'
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
if "headers" in server_config:
|
|
121
|
+
if not isinstance(server_config["headers"], dict):
|
|
122
|
+
return False, (
|
|
123
|
+
f'Server "{server_name}": "headers" must be an object, '
|
|
124
|
+
f'got {type(server_config["headers"]).__name__}'
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
for key, value in server_config["headers"].items():
|
|
128
|
+
if not isinstance(value, str):
|
|
129
|
+
return False, (
|
|
130
|
+
f'Server "{server_name}": headers["{key}"] must be a string, '
|
|
131
|
+
f'got {type(value).__name__}'
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
return True, None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def validate_gateway_rules(rules: dict) -> tuple[bool, Optional[str]]:
|
|
138
|
+
"""Validate gateway rules configuration structure.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
rules: Dictionary containing gateway rules configuration
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
Tuple of (is_valid, error_message). Returns (True, None) if valid,
|
|
145
|
+
(False, error_message) if invalid.
|
|
146
|
+
"""
|
|
147
|
+
# Validate top-level structure
|
|
148
|
+
if not isinstance(rules, dict):
|
|
149
|
+
return False, f"Gateway rules configuration must be a JSON object, got {type(rules).__name__}"
|
|
150
|
+
|
|
151
|
+
# Validate agents section
|
|
152
|
+
if "agents" in rules:
|
|
153
|
+
agents = rules["agents"]
|
|
154
|
+
if not isinstance(agents, dict):
|
|
155
|
+
return False, f'"agents" must be an object, got {type(agents).__name__}'
|
|
156
|
+
|
|
157
|
+
for agent_id, agent_config in agents.items():
|
|
158
|
+
# Validate agent ID format (support hierarchical: team.role)
|
|
159
|
+
if not isinstance(agent_id, str) or not agent_id:
|
|
160
|
+
return False, f"Agent ID must be a non-empty string, got {repr(agent_id)}"
|
|
161
|
+
|
|
162
|
+
if not re.match(r'^[a-zA-Z0-9_.-]+$', agent_id):
|
|
163
|
+
return False, (
|
|
164
|
+
f'Agent ID "{agent_id}" contains invalid characters. '
|
|
165
|
+
f'Only alphanumeric, underscore, dot, and hyphen allowed.'
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
if not isinstance(agent_config, dict):
|
|
169
|
+
return False, (
|
|
170
|
+
f'Agent "{agent_id}" configuration must be an object, '
|
|
171
|
+
f'got {type(agent_config).__name__}'
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
# Validate allow/deny sections
|
|
175
|
+
for section in ["allow", "deny"]:
|
|
176
|
+
if section not in agent_config:
|
|
177
|
+
continue
|
|
178
|
+
|
|
179
|
+
section_config = agent_config[section]
|
|
180
|
+
if not isinstance(section_config, dict):
|
|
181
|
+
return False, (
|
|
182
|
+
f'Agent "{agent_id}" {section} section must be an object, '
|
|
183
|
+
f'got {type(section_config).__name__}'
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# Validate servers list
|
|
187
|
+
if "servers" in section_config:
|
|
188
|
+
servers = section_config["servers"]
|
|
189
|
+
if not isinstance(servers, list):
|
|
190
|
+
return False, (
|
|
191
|
+
f'Agent "{agent_id}" {section}.servers must be an array, '
|
|
192
|
+
f'got {type(servers).__name__}'
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
for i, server in enumerate(servers):
|
|
196
|
+
if not isinstance(server, str):
|
|
197
|
+
return False, (
|
|
198
|
+
f'Agent "{agent_id}" {section}.servers[{i}] must be a string, '
|
|
199
|
+
f'got {type(server).__name__}'
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
# Validate wildcard patterns
|
|
203
|
+
if '*' in server and server != '*':
|
|
204
|
+
return False, (
|
|
205
|
+
f'Agent "{agent_id}" {section}.servers[{i}]: '
|
|
206
|
+
f'wildcard "*" can only be used alone, not in patterns'
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
# Validate tools mapping
|
|
210
|
+
if "tools" in section_config:
|
|
211
|
+
tools = section_config["tools"]
|
|
212
|
+
if not isinstance(tools, dict):
|
|
213
|
+
return False, (
|
|
214
|
+
f'Agent "{agent_id}" {section}.tools must be an object, '
|
|
215
|
+
f'got {type(tools).__name__}'
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
for server_name, tool_patterns in tools.items():
|
|
219
|
+
if not isinstance(tool_patterns, list):
|
|
220
|
+
return False, (
|
|
221
|
+
f'Agent "{agent_id}" {section}.tools["{server_name}"] '
|
|
222
|
+
f'must be an array, got {type(tool_patterns).__name__}'
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
for i, pattern in enumerate(tool_patterns):
|
|
226
|
+
if not isinstance(pattern, str):
|
|
227
|
+
return False, (
|
|
228
|
+
f'Agent "{agent_id}" {section}.tools["{server_name}"][{i}] '
|
|
229
|
+
f'must be a string, got {type(pattern).__name__}'
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
# Validate wildcard patterns (support get_*, *, *_query, etc.)
|
|
233
|
+
if '*' in pattern:
|
|
234
|
+
# Ensure only one wildcard and it's either alone or at start/end
|
|
235
|
+
wildcard_count = pattern.count('*')
|
|
236
|
+
if wildcard_count > 1:
|
|
237
|
+
return False, (
|
|
238
|
+
f'Agent "{agent_id}" {section}.tools["{server_name}"][{i}]: '
|
|
239
|
+
f'pattern "{pattern}" contains multiple wildcards - only one allowed'
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
if pattern != '*' and not (pattern.startswith('*') or pattern.endswith('*')):
|
|
243
|
+
return False, (
|
|
244
|
+
f'Agent "{agent_id}" {section}.tools["{server_name}"][{i}]: '
|
|
245
|
+
f'wildcard in pattern "{pattern}" must be at start, end, or alone'
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
# Validate defaults section
|
|
249
|
+
if "defaults" in rules:
|
|
250
|
+
defaults = rules["defaults"]
|
|
251
|
+
if not isinstance(defaults, dict):
|
|
252
|
+
return False, f'"defaults" must be an object, got {type(defaults).__name__}'
|
|
253
|
+
|
|
254
|
+
if "deny_on_missing_agent" in defaults:
|
|
255
|
+
deny_on_missing = defaults["deny_on_missing_agent"]
|
|
256
|
+
if not isinstance(deny_on_missing, bool):
|
|
257
|
+
return False, (
|
|
258
|
+
f'"defaults.deny_on_missing_agent" must be a boolean, '
|
|
259
|
+
f'got {type(deny_on_missing).__name__}'
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
return True, None
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def reload_configs(
|
|
266
|
+
mcp_config_path: str,
|
|
267
|
+
gateway_rules_path: str
|
|
268
|
+
) -> tuple[Optional[dict], Optional[dict], Optional[str]]:
|
|
269
|
+
"""Reload and validate both MCP config and gateway rules.
|
|
270
|
+
|
|
271
|
+
This function loads both configuration files from disk and validates them
|
|
272
|
+
without applying them to the running system. It's designed to be called
|
|
273
|
+
before actually updating the gateway's configuration to ensure the new
|
|
274
|
+
configs are valid.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
mcp_config_path: Path to MCP servers configuration file
|
|
278
|
+
gateway_rules_path: Path to gateway rules configuration file
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
Tuple of (mcp_config, gateway_rules, error_message).
|
|
282
|
+
- If both configs are valid: (mcp_config_dict, gateway_rules_dict, None)
|
|
283
|
+
- If either config is invalid: (None, None, error_message)
|
|
284
|
+
|
|
285
|
+
Note:
|
|
286
|
+
This function does NOT perform environment variable substitution
|
|
287
|
+
on the MCP config, as that's handled by load_mcp_config(). The
|
|
288
|
+
returned configs are the raw JSON data after validation.
|
|
289
|
+
"""
|
|
290
|
+
# Expand paths
|
|
291
|
+
mcp_path = Path(mcp_config_path).expanduser().resolve()
|
|
292
|
+
rules_path = Path(gateway_rules_path).expanduser().resolve()
|
|
293
|
+
|
|
294
|
+
# Load MCP config
|
|
295
|
+
try:
|
|
296
|
+
if not mcp_path.exists():
|
|
297
|
+
return None, None, f"MCP server configuration file not found: {mcp_path}"
|
|
298
|
+
|
|
299
|
+
with open(mcp_path, 'r', encoding='utf-8') as f:
|
|
300
|
+
mcp_config = json.load(f)
|
|
301
|
+
except json.JSONDecodeError as e:
|
|
302
|
+
return None, None, f"Invalid JSON in MCP server configuration: {e.msg}"
|
|
303
|
+
except Exception as e:
|
|
304
|
+
return None, None, f"Error loading MCP config: {str(e)}"
|
|
305
|
+
|
|
306
|
+
# Validate MCP config structure
|
|
307
|
+
valid, error = validate_mcp_config(mcp_config)
|
|
308
|
+
if not valid:
|
|
309
|
+
return None, None, f"Invalid MCP config: {error}"
|
|
310
|
+
|
|
311
|
+
# Load gateway rules
|
|
312
|
+
try:
|
|
313
|
+
if not rules_path.exists():
|
|
314
|
+
return None, None, f"Gateway rules configuration file not found: {rules_path}"
|
|
315
|
+
|
|
316
|
+
with open(rules_path, 'r', encoding='utf-8') as f:
|
|
317
|
+
gateway_rules = json.load(f)
|
|
318
|
+
except json.JSONDecodeError as e:
|
|
319
|
+
return None, None, f"Invalid JSON in gateway rules configuration: {e.msg}"
|
|
320
|
+
except Exception as e:
|
|
321
|
+
return None, None, f"Error loading gateway rules: {str(e)}"
|
|
322
|
+
|
|
323
|
+
# Validate gateway rules structure
|
|
324
|
+
valid, error = validate_gateway_rules(gateway_rules)
|
|
325
|
+
if not valid:
|
|
326
|
+
return None, None, f"Invalid gateway rules: {error}"
|
|
327
|
+
|
|
328
|
+
# Cross-validate: check that servers referenced in rules exist in config
|
|
329
|
+
global _last_validation_warnings
|
|
330
|
+
warnings = validate_rules_against_servers(gateway_rules, mcp_config)
|
|
331
|
+
_last_validation_warnings = warnings # Store for diagnostics
|
|
332
|
+
|
|
333
|
+
if warnings:
|
|
334
|
+
# Log warnings but continue - undefined servers are not fatal
|
|
335
|
+
warning_text = "\n".join(f" - {w}" for w in warnings)
|
|
336
|
+
|
|
337
|
+
# Log to Python logger
|
|
338
|
+
logger.warning(
|
|
339
|
+
"Gateway rules reference servers not currently loaded:\n%s",
|
|
340
|
+
warning_text
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
# Log to stderr for visibility
|
|
344
|
+
print(
|
|
345
|
+
"[HOT RELOAD WARNING] Gateway rules reference servers not currently loaded:",
|
|
346
|
+
file=sys.stderr
|
|
347
|
+
)
|
|
348
|
+
for warning in warnings:
|
|
349
|
+
print(f" - {warning}", file=sys.stderr)
|
|
350
|
+
print(
|
|
351
|
+
"[HOT RELOAD WARNING] These rules will be ignored until the servers are added to .mcp.json",
|
|
352
|
+
file=sys.stderr
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
return mcp_config, gateway_rules, None
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def load_mcp_config(path: str) -> dict:
|
|
359
|
+
"""Load and validate MCP server configuration.
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
path: Path to MCP servers configuration file
|
|
363
|
+
|
|
364
|
+
Returns:
|
|
365
|
+
Dictionary containing mcpServers configuration
|
|
366
|
+
|
|
367
|
+
Raises:
|
|
368
|
+
FileNotFoundError: If config file doesn't exist
|
|
369
|
+
ValueError: If config is invalid or malformed
|
|
370
|
+
json.JSONDecodeError: If config is not valid JSON
|
|
371
|
+
"""
|
|
372
|
+
global _mcp_config_path
|
|
373
|
+
|
|
374
|
+
# Expand user paths and convert to absolute
|
|
375
|
+
config_path = Path(path).expanduser().resolve()
|
|
376
|
+
|
|
377
|
+
# Store the path for future reloads
|
|
378
|
+
_mcp_config_path = str(config_path)
|
|
379
|
+
|
|
380
|
+
# Check if file exists
|
|
381
|
+
if not config_path.exists():
|
|
382
|
+
raise FileNotFoundError(
|
|
383
|
+
f"MCP server configuration file not found: {config_path}"
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
# Load JSON
|
|
387
|
+
try:
|
|
388
|
+
with open(config_path, 'r', encoding='utf-8') as f:
|
|
389
|
+
config = json.load(f)
|
|
390
|
+
except json.JSONDecodeError as e:
|
|
391
|
+
raise json.JSONDecodeError(
|
|
392
|
+
f"Invalid JSON in MCP server configuration: {e.msg}",
|
|
393
|
+
e.doc,
|
|
394
|
+
e.pos
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
# Validate top-level structure
|
|
398
|
+
if not isinstance(config, dict):
|
|
399
|
+
raise ValueError(
|
|
400
|
+
f"MCP server configuration must be a JSON object, got {type(config).__name__}"
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
if "mcpServers" not in config:
|
|
404
|
+
raise ValueError(
|
|
405
|
+
'MCP server configuration must contain "mcpServers" key'
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
mcp_servers = config["mcpServers"]
|
|
409
|
+
if not isinstance(mcp_servers, dict):
|
|
410
|
+
raise ValueError(
|
|
411
|
+
f'"mcpServers" must be an object, got {type(mcp_servers).__name__}'
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
# Validate each server configuration
|
|
415
|
+
for server_name, server_config in mcp_servers.items():
|
|
416
|
+
if not isinstance(server_config, dict):
|
|
417
|
+
raise ValueError(
|
|
418
|
+
f'Server "{server_name}" configuration must be an object, '
|
|
419
|
+
f'got {type(server_config).__name__}'
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
# Determine transport type and validate required fields
|
|
423
|
+
has_command = "command" in server_config
|
|
424
|
+
has_url = "url" in server_config
|
|
425
|
+
|
|
426
|
+
if has_command and has_url:
|
|
427
|
+
raise ValueError(
|
|
428
|
+
f'Server "{server_name}" cannot have both "command" (stdio) '
|
|
429
|
+
f'and "url" (HTTP) - specify one transport type only'
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
if not has_command and not has_url:
|
|
433
|
+
raise ValueError(
|
|
434
|
+
f'Server "{server_name}" must specify either "command" (stdio) '
|
|
435
|
+
f'or "url" (HTTP) transport'
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
# Validate stdio transport
|
|
439
|
+
if has_command:
|
|
440
|
+
if not isinstance(server_config["command"], str):
|
|
441
|
+
raise ValueError(
|
|
442
|
+
f'Server "{server_name}": "command" must be a string, '
|
|
443
|
+
f'got {type(server_config["command"]).__name__}'
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
if "args" in server_config:
|
|
447
|
+
if not isinstance(server_config["args"], list):
|
|
448
|
+
raise ValueError(
|
|
449
|
+
f'Server "{server_name}": "args" must be an array, '
|
|
450
|
+
f'got {type(server_config["args"]).__name__}'
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
for i, arg in enumerate(server_config["args"]):
|
|
454
|
+
if not isinstance(arg, str):
|
|
455
|
+
raise ValueError(
|
|
456
|
+
f'Server "{server_name}": args[{i}] must be a string, '
|
|
457
|
+
f'got {type(arg).__name__}'
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
if "env" in server_config:
|
|
461
|
+
if not isinstance(server_config["env"], dict):
|
|
462
|
+
raise ValueError(
|
|
463
|
+
f'Server "{server_name}": "env" must be an object, '
|
|
464
|
+
f'got {type(server_config["env"]).__name__}'
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
for key, value in server_config["env"].items():
|
|
468
|
+
if not isinstance(value, str):
|
|
469
|
+
raise ValueError(
|
|
470
|
+
f'Server "{server_name}": env["{key}"] must be a string, '
|
|
471
|
+
f'got {type(value).__name__}'
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
# Validate HTTP transport
|
|
475
|
+
if has_url:
|
|
476
|
+
if not isinstance(server_config["url"], str):
|
|
477
|
+
raise ValueError(
|
|
478
|
+
f'Server "{server_name}": "url" must be a string, '
|
|
479
|
+
f'got {type(server_config["url"]).__name__}'
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
# Basic URL validation
|
|
483
|
+
url = server_config["url"]
|
|
484
|
+
if not (url.startswith("http://") or url.startswith("https://")):
|
|
485
|
+
raise ValueError(
|
|
486
|
+
f'Server "{server_name}": "url" must start with http:// or https://, '
|
|
487
|
+
f'got "{url}"'
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
if "headers" in server_config:
|
|
491
|
+
if not isinstance(server_config["headers"], dict):
|
|
492
|
+
raise ValueError(
|
|
493
|
+
f'Server "{server_name}": "headers" must be an object, '
|
|
494
|
+
f'got {type(server_config["headers"]).__name__}'
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
for key, value in server_config["headers"].items():
|
|
498
|
+
if not isinstance(value, str):
|
|
499
|
+
raise ValueError(
|
|
500
|
+
f'Server "{server_name}": headers["{key}"] must be a string, '
|
|
501
|
+
f'got {type(value).__name__}'
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
# Perform environment variable substitution
|
|
505
|
+
config = _substitute_env_vars(config)
|
|
506
|
+
|
|
507
|
+
return config
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def load_gateway_rules(path: str) -> dict:
|
|
511
|
+
"""Load and validate gateway rules configuration.
|
|
512
|
+
|
|
513
|
+
Args:
|
|
514
|
+
path: Path to gateway rules configuration file
|
|
515
|
+
|
|
516
|
+
Returns:
|
|
517
|
+
Dictionary containing agent policies and defaults
|
|
518
|
+
|
|
519
|
+
Raises:
|
|
520
|
+
FileNotFoundError: If rules file doesn't exist
|
|
521
|
+
ValueError: If rules are invalid or malformed
|
|
522
|
+
json.JSONDecodeError: If rules are not valid JSON
|
|
523
|
+
"""
|
|
524
|
+
global _gateway_rules_path
|
|
525
|
+
|
|
526
|
+
# Expand user paths and convert to absolute
|
|
527
|
+
rules_path = Path(path).expanduser().resolve()
|
|
528
|
+
|
|
529
|
+
# Store the path for future reloads
|
|
530
|
+
_gateway_rules_path = str(rules_path)
|
|
531
|
+
|
|
532
|
+
# Check if file exists
|
|
533
|
+
if not rules_path.exists():
|
|
534
|
+
raise FileNotFoundError(
|
|
535
|
+
f"Gateway rules configuration file not found: {rules_path}"
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
# Load JSON
|
|
539
|
+
try:
|
|
540
|
+
with open(rules_path, 'r', encoding='utf-8') as f:
|
|
541
|
+
rules = json.load(f)
|
|
542
|
+
except json.JSONDecodeError as e:
|
|
543
|
+
raise json.JSONDecodeError(
|
|
544
|
+
f"Invalid JSON in gateway rules configuration: {e.msg}",
|
|
545
|
+
e.doc,
|
|
546
|
+
e.pos
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
# Validate top-level structure
|
|
550
|
+
if not isinstance(rules, dict):
|
|
551
|
+
raise ValueError(
|
|
552
|
+
f"Gateway rules configuration must be a JSON object, got {type(rules).__name__}"
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
# Validate agents section
|
|
556
|
+
if "agents" in rules:
|
|
557
|
+
agents = rules["agents"]
|
|
558
|
+
if not isinstance(agents, dict):
|
|
559
|
+
raise ValueError(
|
|
560
|
+
f'"agents" must be an object, got {type(agents).__name__}'
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
for agent_id, agent_config in agents.items():
|
|
564
|
+
# Validate agent ID format (support hierarchical: team.role)
|
|
565
|
+
if not isinstance(agent_id, str) or not agent_id:
|
|
566
|
+
raise ValueError(
|
|
567
|
+
f"Agent ID must be a non-empty string, got {repr(agent_id)}"
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
if not re.match(r'^[a-zA-Z0-9_.-]+$', agent_id):
|
|
571
|
+
raise ValueError(
|
|
572
|
+
f'Agent ID "{agent_id}" contains invalid characters. '
|
|
573
|
+
f'Only alphanumeric, underscore, dot, and hyphen allowed.'
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
if not isinstance(agent_config, dict):
|
|
577
|
+
raise ValueError(
|
|
578
|
+
f'Agent "{agent_id}" configuration must be an object, '
|
|
579
|
+
f'got {type(agent_config).__name__}'
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
# Validate allow/deny sections
|
|
583
|
+
for section in ["allow", "deny"]:
|
|
584
|
+
if section not in agent_config:
|
|
585
|
+
continue
|
|
586
|
+
|
|
587
|
+
section_config = agent_config[section]
|
|
588
|
+
if not isinstance(section_config, dict):
|
|
589
|
+
raise ValueError(
|
|
590
|
+
f'Agent "{agent_id}" {section} section must be an object, '
|
|
591
|
+
f'got {type(section_config).__name__}'
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
# Validate servers list
|
|
595
|
+
if "servers" in section_config:
|
|
596
|
+
servers = section_config["servers"]
|
|
597
|
+
if not isinstance(servers, list):
|
|
598
|
+
raise ValueError(
|
|
599
|
+
f'Agent "{agent_id}" {section}.servers must be an array, '
|
|
600
|
+
f'got {type(servers).__name__}'
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
for i, server in enumerate(servers):
|
|
604
|
+
if not isinstance(server, str):
|
|
605
|
+
raise ValueError(
|
|
606
|
+
f'Agent "{agent_id}" {section}.servers[{i}] must be a string, '
|
|
607
|
+
f'got {type(server).__name__}'
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
# Validate wildcard patterns
|
|
611
|
+
if '*' in server and server != '*':
|
|
612
|
+
raise ValueError(
|
|
613
|
+
f'Agent "{agent_id}" {section}.servers[{i}]: '
|
|
614
|
+
f'wildcard "*" can only be used alone, not in patterns'
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
# Validate tools mapping
|
|
618
|
+
if "tools" in section_config:
|
|
619
|
+
tools = section_config["tools"]
|
|
620
|
+
if not isinstance(tools, dict):
|
|
621
|
+
raise ValueError(
|
|
622
|
+
f'Agent "{agent_id}" {section}.tools must be an object, '
|
|
623
|
+
f'got {type(tools).__name__}'
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
for server_name, tool_patterns in tools.items():
|
|
627
|
+
if not isinstance(tool_patterns, list):
|
|
628
|
+
raise ValueError(
|
|
629
|
+
f'Agent "{agent_id}" {section}.tools["{server_name}"] '
|
|
630
|
+
f'must be an array, got {type(tool_patterns).__name__}'
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
for i, pattern in enumerate(tool_patterns):
|
|
634
|
+
if not isinstance(pattern, str):
|
|
635
|
+
raise ValueError(
|
|
636
|
+
f'Agent "{agent_id}" {section}.tools["{server_name}"][{i}] '
|
|
637
|
+
f'must be a string, got {type(pattern).__name__}'
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
# Validate wildcard patterns (support get_*, *, *_query, etc.)
|
|
641
|
+
if '*' in pattern:
|
|
642
|
+
# Ensure only one wildcard and it's either alone or at start/end
|
|
643
|
+
wildcard_count = pattern.count('*')
|
|
644
|
+
if wildcard_count > 1:
|
|
645
|
+
raise ValueError(
|
|
646
|
+
f'Agent "{agent_id}" {section}.tools["{server_name}"][{i}]: '
|
|
647
|
+
f'pattern "{pattern}" contains multiple wildcards - only one allowed'
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
if pattern != '*' and not (pattern.startswith('*') or pattern.endswith('*')):
|
|
651
|
+
raise ValueError(
|
|
652
|
+
f'Agent "{agent_id}" {section}.tools["{server_name}"][{i}]: '
|
|
653
|
+
f'wildcard in pattern "{pattern}" must be at start, end, or alone'
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
# Validate defaults section
|
|
657
|
+
if "defaults" in rules:
|
|
658
|
+
defaults = rules["defaults"]
|
|
659
|
+
if not isinstance(defaults, dict):
|
|
660
|
+
raise ValueError(
|
|
661
|
+
f'"defaults" must be an object, got {type(defaults).__name__}'
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
if "deny_on_missing_agent" in defaults:
|
|
665
|
+
deny_on_missing = defaults["deny_on_missing_agent"]
|
|
666
|
+
if not isinstance(deny_on_missing, bool):
|
|
667
|
+
raise ValueError(
|
|
668
|
+
f'"defaults.deny_on_missing_agent" must be a boolean, '
|
|
669
|
+
f'got {type(deny_on_missing).__name__}'
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
return rules
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _substitute_env_vars(obj: Any) -> Any:
|
|
676
|
+
"""Recursively substitute ${VAR} with environment variables.
|
|
677
|
+
|
|
678
|
+
Args:
|
|
679
|
+
obj: Object to process (str, dict, list, or other)
|
|
680
|
+
|
|
681
|
+
Returns:
|
|
682
|
+
Object with environment variables substituted
|
|
683
|
+
|
|
684
|
+
Raises:
|
|
685
|
+
ValueError: If referenced environment variable is not set
|
|
686
|
+
"""
|
|
687
|
+
if isinstance(obj, str):
|
|
688
|
+
# Find all ${VAR} patterns
|
|
689
|
+
pattern = re.compile(r'\$\{([^}]+)\}')
|
|
690
|
+
|
|
691
|
+
def replace_var(match):
|
|
692
|
+
var_name = match.group(1)
|
|
693
|
+
if var_name not in os.environ:
|
|
694
|
+
raise ValueError(
|
|
695
|
+
f'Environment variable "{var_name}" referenced in configuration '
|
|
696
|
+
f'but not set. Please set this variable before starting the gateway.'
|
|
697
|
+
)
|
|
698
|
+
return os.environ[var_name]
|
|
699
|
+
|
|
700
|
+
return pattern.sub(replace_var, obj)
|
|
701
|
+
|
|
702
|
+
elif isinstance(obj, dict):
|
|
703
|
+
return {key: _substitute_env_vars(value) for key, value in obj.items()}
|
|
704
|
+
|
|
705
|
+
elif isinstance(obj, list):
|
|
706
|
+
return [_substitute_env_vars(item) for item in obj]
|
|
707
|
+
|
|
708
|
+
else:
|
|
709
|
+
# Return other types unchanged (int, bool, None, etc.)
|
|
710
|
+
return obj
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def get_mcp_config_path() -> str:
|
|
714
|
+
"""Get MCP configuration file path using standard search order.
|
|
715
|
+
|
|
716
|
+
Search order:
|
|
717
|
+
1. GATEWAY_MCP_CONFIG environment variable (if set)
|
|
718
|
+
2. .mcp.json in current working directory
|
|
719
|
+
3. ~/.config/agent-mcp-gateway/.mcp.json (home directory)
|
|
720
|
+
4. ./config/.mcp.json (fallback)
|
|
721
|
+
|
|
722
|
+
Returns:
|
|
723
|
+
Resolved path to .mcp.json configuration file
|
|
724
|
+
"""
|
|
725
|
+
# Check environment variable first
|
|
726
|
+
if env_path := os.getenv("GATEWAY_MCP_CONFIG"):
|
|
727
|
+
return str(Path(env_path).expanduser().resolve())
|
|
728
|
+
|
|
729
|
+
# Check current working directory
|
|
730
|
+
cwd_path = Path.cwd() / ".mcp.json"
|
|
731
|
+
if cwd_path.exists():
|
|
732
|
+
return str(cwd_path.resolve())
|
|
733
|
+
|
|
734
|
+
# Check home directory
|
|
735
|
+
home_path = Path.home() / ".config" / "agent-mcp-gateway" / ".mcp.json"
|
|
736
|
+
if home_path.exists():
|
|
737
|
+
return str(home_path.resolve())
|
|
738
|
+
|
|
739
|
+
# Fallback to config directory
|
|
740
|
+
return str(Path("./config/.mcp.json").expanduser().resolve())
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def get_gateway_rules_path() -> str:
|
|
744
|
+
"""Get MCP Gateway rules file path using standard search order.
|
|
745
|
+
|
|
746
|
+
Search order:
|
|
747
|
+
1. GATEWAY_RULES environment variable (if set)
|
|
748
|
+
2. .mcp-gateway-rules.json in current working directory
|
|
749
|
+
3. ~/.config/agent-mcp-gateway/.mcp-gateway-rules.json (home directory)
|
|
750
|
+
4. ./config/.mcp-gateway-rules.json (fallback)
|
|
751
|
+
|
|
752
|
+
Returns:
|
|
753
|
+
Resolved path to .mcp-gateway-rules.json configuration file
|
|
754
|
+
"""
|
|
755
|
+
# Check environment variable first
|
|
756
|
+
if env_path := os.getenv("GATEWAY_RULES"):
|
|
757
|
+
return str(Path(env_path).expanduser().resolve())
|
|
758
|
+
|
|
759
|
+
# Check current working directory
|
|
760
|
+
cwd_path = Path.cwd() / ".mcp-gateway-rules.json"
|
|
761
|
+
if cwd_path.exists():
|
|
762
|
+
return str(cwd_path.resolve())
|
|
763
|
+
|
|
764
|
+
# Check home directory
|
|
765
|
+
home_path = Path.home() / ".config" / "agent-mcp-gateway" / ".mcp-gateway-rules.json"
|
|
766
|
+
if home_path.exists():
|
|
767
|
+
return str(home_path.resolve())
|
|
768
|
+
|
|
769
|
+
# Fallback to config directory
|
|
770
|
+
return str(Path("./config/.mcp-gateway-rules.json").expanduser().resolve())
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def get_config_path(env_var: str, default: str) -> str:
|
|
774
|
+
"""Get configuration file path from environment variable or use default.
|
|
775
|
+
|
|
776
|
+
Args:
|
|
777
|
+
env_var: Environment variable name to check
|
|
778
|
+
default: Default path if environment variable not set
|
|
779
|
+
|
|
780
|
+
Returns:
|
|
781
|
+
Resolved configuration file path
|
|
782
|
+
"""
|
|
783
|
+
path = os.environ.get(env_var, default)
|
|
784
|
+
return str(Path(path).expanduser().resolve())
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def validate_rules_against_servers(rules: dict, mcp_config: dict) -> list[str]:
|
|
788
|
+
"""Validate that all servers referenced in rules exist in MCP config.
|
|
789
|
+
|
|
790
|
+
Args:
|
|
791
|
+
rules: Gateway rules configuration
|
|
792
|
+
mcp_config: MCP servers configuration
|
|
793
|
+
|
|
794
|
+
Returns:
|
|
795
|
+
List of warning messages (empty if all valid)
|
|
796
|
+
"""
|
|
797
|
+
warnings = []
|
|
798
|
+
|
|
799
|
+
if "agents" not in rules:
|
|
800
|
+
return warnings
|
|
801
|
+
|
|
802
|
+
available_servers = set(mcp_config.get("mcpServers", {}).keys())
|
|
803
|
+
|
|
804
|
+
for agent_id, agent_config in rules["agents"].items():
|
|
805
|
+
for section in ["allow", "deny"]:
|
|
806
|
+
if section not in agent_config:
|
|
807
|
+
continue
|
|
808
|
+
|
|
809
|
+
section_config = agent_config[section]
|
|
810
|
+
|
|
811
|
+
# Check servers list
|
|
812
|
+
if "servers" in section_config:
|
|
813
|
+
for server in section_config["servers"]:
|
|
814
|
+
if server != "*" and server not in available_servers:
|
|
815
|
+
warnings.append(
|
|
816
|
+
f'Agent "{agent_id}" {section}.servers references '
|
|
817
|
+
f'undefined server "{server}"'
|
|
818
|
+
)
|
|
819
|
+
|
|
820
|
+
# Check tools mapping
|
|
821
|
+
if "tools" in section_config:
|
|
822
|
+
for server_name in section_config["tools"].keys():
|
|
823
|
+
if server_name not in available_servers:
|
|
824
|
+
warnings.append(
|
|
825
|
+
f'Agent "{agent_id}" {section}.tools references '
|
|
826
|
+
f'undefined server "{server_name}"'
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
return warnings
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def get_stored_config_paths() -> tuple[Optional[str], Optional[str]]:
|
|
833
|
+
"""Get the stored configuration file paths.
|
|
834
|
+
|
|
835
|
+
Returns:
|
|
836
|
+
Tuple of (mcp_config_path, gateway_rules_path). Either or both may be None
|
|
837
|
+
if the corresponding config has not been loaded yet.
|
|
838
|
+
"""
|
|
839
|
+
return _mcp_config_path, _gateway_rules_path
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def get_last_validation_warnings() -> list[str]:
|
|
843
|
+
"""Get warnings from the last config validation.
|
|
844
|
+
|
|
845
|
+
Returns:
|
|
846
|
+
List of warning messages from the last reload_configs() call.
|
|
847
|
+
Empty list if no warnings or no reload has occurred yet.
|
|
848
|
+
"""
|
|
849
|
+
return _last_validation_warnings.copy()
|