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.
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()