atlas-chat 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (250) hide show
  1. atlas/__init__.py +40 -0
  2. atlas/application/__init__.py +7 -0
  3. atlas/application/chat/__init__.py +7 -0
  4. atlas/application/chat/agent/__init__.py +10 -0
  5. atlas/application/chat/agent/act_loop.py +179 -0
  6. atlas/application/chat/agent/factory.py +142 -0
  7. atlas/application/chat/agent/protocols.py +46 -0
  8. atlas/application/chat/agent/react_loop.py +338 -0
  9. atlas/application/chat/agent/think_act_loop.py +171 -0
  10. atlas/application/chat/approval_manager.py +151 -0
  11. atlas/application/chat/elicitation_manager.py +191 -0
  12. atlas/application/chat/events/__init__.py +1 -0
  13. atlas/application/chat/events/agent_event_relay.py +112 -0
  14. atlas/application/chat/modes/__init__.py +1 -0
  15. atlas/application/chat/modes/agent.py +125 -0
  16. atlas/application/chat/modes/plain.py +74 -0
  17. atlas/application/chat/modes/rag.py +81 -0
  18. atlas/application/chat/modes/tools.py +179 -0
  19. atlas/application/chat/orchestrator.py +213 -0
  20. atlas/application/chat/policies/__init__.py +1 -0
  21. atlas/application/chat/policies/tool_authorization.py +99 -0
  22. atlas/application/chat/preprocessors/__init__.py +1 -0
  23. atlas/application/chat/preprocessors/message_builder.py +92 -0
  24. atlas/application/chat/preprocessors/prompt_override_service.py +104 -0
  25. atlas/application/chat/service.py +454 -0
  26. atlas/application/chat/utilities/__init__.py +6 -0
  27. atlas/application/chat/utilities/error_handler.py +367 -0
  28. atlas/application/chat/utilities/event_notifier.py +546 -0
  29. atlas/application/chat/utilities/file_processor.py +613 -0
  30. atlas/application/chat/utilities/tool_executor.py +789 -0
  31. atlas/atlas_chat_cli.py +347 -0
  32. atlas/atlas_client.py +238 -0
  33. atlas/core/__init__.py +0 -0
  34. atlas/core/auth.py +205 -0
  35. atlas/core/authorization_manager.py +27 -0
  36. atlas/core/capabilities.py +123 -0
  37. atlas/core/compliance.py +215 -0
  38. atlas/core/domain_whitelist.py +147 -0
  39. atlas/core/domain_whitelist_middleware.py +82 -0
  40. atlas/core/http_client.py +28 -0
  41. atlas/core/log_sanitizer.py +102 -0
  42. atlas/core/metrics_logger.py +59 -0
  43. atlas/core/middleware.py +131 -0
  44. atlas/core/otel_config.py +242 -0
  45. atlas/core/prompt_risk.py +200 -0
  46. atlas/core/rate_limit.py +0 -0
  47. atlas/core/rate_limit_middleware.py +64 -0
  48. atlas/core/security_headers_middleware.py +51 -0
  49. atlas/domain/__init__.py +37 -0
  50. atlas/domain/chat/__init__.py +1 -0
  51. atlas/domain/chat/dtos.py +85 -0
  52. atlas/domain/errors.py +96 -0
  53. atlas/domain/messages/__init__.py +12 -0
  54. atlas/domain/messages/models.py +160 -0
  55. atlas/domain/rag_mcp_service.py +664 -0
  56. atlas/domain/sessions/__init__.py +7 -0
  57. atlas/domain/sessions/models.py +36 -0
  58. atlas/domain/unified_rag_service.py +371 -0
  59. atlas/infrastructure/__init__.py +10 -0
  60. atlas/infrastructure/app_factory.py +135 -0
  61. atlas/infrastructure/events/__init__.py +1 -0
  62. atlas/infrastructure/events/cli_event_publisher.py +140 -0
  63. atlas/infrastructure/events/websocket_publisher.py +140 -0
  64. atlas/infrastructure/sessions/in_memory_repository.py +56 -0
  65. atlas/infrastructure/transport/__init__.py +7 -0
  66. atlas/infrastructure/transport/websocket_connection_adapter.py +33 -0
  67. atlas/init_cli.py +226 -0
  68. atlas/interfaces/__init__.py +15 -0
  69. atlas/interfaces/events.py +134 -0
  70. atlas/interfaces/llm.py +54 -0
  71. atlas/interfaces/rag.py +40 -0
  72. atlas/interfaces/sessions.py +75 -0
  73. atlas/interfaces/tools.py +57 -0
  74. atlas/interfaces/transport.py +24 -0
  75. atlas/main.py +564 -0
  76. atlas/mcp/api_key_demo/README.md +76 -0
  77. atlas/mcp/api_key_demo/main.py +172 -0
  78. atlas/mcp/api_key_demo/run.sh +56 -0
  79. atlas/mcp/basictable/main.py +147 -0
  80. atlas/mcp/calculator/main.py +149 -0
  81. atlas/mcp/code-executor/execution_engine.py +98 -0
  82. atlas/mcp/code-executor/execution_environment.py +95 -0
  83. atlas/mcp/code-executor/main.py +528 -0
  84. atlas/mcp/code-executor/result_processing.py +276 -0
  85. atlas/mcp/code-executor/script_generation.py +195 -0
  86. atlas/mcp/code-executor/security_checker.py +140 -0
  87. atlas/mcp/corporate_cars/main.py +437 -0
  88. atlas/mcp/csv_reporter/main.py +545 -0
  89. atlas/mcp/duckduckgo/main.py +182 -0
  90. atlas/mcp/elicitation_demo/README.md +171 -0
  91. atlas/mcp/elicitation_demo/main.py +262 -0
  92. atlas/mcp/env-demo/README.md +158 -0
  93. atlas/mcp/env-demo/main.py +199 -0
  94. atlas/mcp/file_size_test/main.py +284 -0
  95. atlas/mcp/filesystem/main.py +348 -0
  96. atlas/mcp/image_demo/main.py +113 -0
  97. atlas/mcp/image_demo/requirements.txt +4 -0
  98. atlas/mcp/logging_demo/README.md +72 -0
  99. atlas/mcp/logging_demo/main.py +103 -0
  100. atlas/mcp/many_tools_demo/main.py +50 -0
  101. atlas/mcp/order_database/__init__.py +0 -0
  102. atlas/mcp/order_database/main.py +369 -0
  103. atlas/mcp/order_database/signal_data.csv +1001 -0
  104. atlas/mcp/pdfbasic/main.py +394 -0
  105. atlas/mcp/pptx_generator/main.py +760 -0
  106. atlas/mcp/pptx_generator/requirements.txt +13 -0
  107. atlas/mcp/pptx_generator/run_test.sh +1 -0
  108. atlas/mcp/pptx_generator/test_pptx_generator_security.py +169 -0
  109. atlas/mcp/progress_demo/main.py +167 -0
  110. atlas/mcp/progress_updates_demo/QUICKSTART.md +273 -0
  111. atlas/mcp/progress_updates_demo/README.md +120 -0
  112. atlas/mcp/progress_updates_demo/main.py +497 -0
  113. atlas/mcp/prompts/main.py +222 -0
  114. atlas/mcp/public_demo/main.py +189 -0
  115. atlas/mcp/sampling_demo/README.md +169 -0
  116. atlas/mcp/sampling_demo/main.py +234 -0
  117. atlas/mcp/thinking/main.py +77 -0
  118. atlas/mcp/tool_planner/main.py +240 -0
  119. atlas/mcp/ui-demo/badmesh.png +0 -0
  120. atlas/mcp/ui-demo/main.py +383 -0
  121. atlas/mcp/ui-demo/templates/button_demo.html +32 -0
  122. atlas/mcp/ui-demo/templates/data_visualization.html +32 -0
  123. atlas/mcp/ui-demo/templates/form_demo.html +28 -0
  124. atlas/mcp/username-override-demo/README.md +320 -0
  125. atlas/mcp/username-override-demo/main.py +308 -0
  126. atlas/modules/__init__.py +0 -0
  127. atlas/modules/config/__init__.py +34 -0
  128. atlas/modules/config/cli.py +231 -0
  129. atlas/modules/config/config_manager.py +1096 -0
  130. atlas/modules/file_storage/__init__.py +22 -0
  131. atlas/modules/file_storage/cli.py +330 -0
  132. atlas/modules/file_storage/content_extractor.py +290 -0
  133. atlas/modules/file_storage/manager.py +295 -0
  134. atlas/modules/file_storage/mock_s3_client.py +402 -0
  135. atlas/modules/file_storage/s3_client.py +417 -0
  136. atlas/modules/llm/__init__.py +19 -0
  137. atlas/modules/llm/caller.py +287 -0
  138. atlas/modules/llm/litellm_caller.py +675 -0
  139. atlas/modules/llm/models.py +19 -0
  140. atlas/modules/mcp_tools/__init__.py +17 -0
  141. atlas/modules/mcp_tools/client.py +2123 -0
  142. atlas/modules/mcp_tools/token_storage.py +556 -0
  143. atlas/modules/prompts/prompt_provider.py +130 -0
  144. atlas/modules/rag/__init__.py +24 -0
  145. atlas/modules/rag/atlas_rag_client.py +336 -0
  146. atlas/modules/rag/client.py +129 -0
  147. atlas/routes/admin_routes.py +865 -0
  148. atlas/routes/config_routes.py +484 -0
  149. atlas/routes/feedback_routes.py +361 -0
  150. atlas/routes/files_routes.py +274 -0
  151. atlas/routes/health_routes.py +40 -0
  152. atlas/routes/mcp_auth_routes.py +223 -0
  153. atlas/server_cli.py +164 -0
  154. atlas/tests/conftest.py +20 -0
  155. atlas/tests/integration/test_mcp_auth_integration.py +152 -0
  156. atlas/tests/manual_test_sampling.py +87 -0
  157. atlas/tests/modules/mcp_tools/test_client_auth.py +226 -0
  158. atlas/tests/modules/mcp_tools/test_client_env.py +191 -0
  159. atlas/tests/test_admin_mcp_server_management_routes.py +141 -0
  160. atlas/tests/test_agent_roa.py +135 -0
  161. atlas/tests/test_app_factory_smoke.py +47 -0
  162. atlas/tests/test_approval_manager.py +439 -0
  163. atlas/tests/test_atlas_client.py +188 -0
  164. atlas/tests/test_atlas_rag_client.py +447 -0
  165. atlas/tests/test_atlas_rag_integration.py +224 -0
  166. atlas/tests/test_attach_file_flow.py +287 -0
  167. atlas/tests/test_auth_utils.py +165 -0
  168. atlas/tests/test_backend_public_url.py +185 -0
  169. atlas/tests/test_banner_logging.py +287 -0
  170. atlas/tests/test_capability_tokens_and_injection.py +203 -0
  171. atlas/tests/test_compliance_level.py +54 -0
  172. atlas/tests/test_compliance_manager.py +253 -0
  173. atlas/tests/test_config_manager.py +617 -0
  174. atlas/tests/test_config_manager_paths.py +12 -0
  175. atlas/tests/test_core_auth.py +18 -0
  176. atlas/tests/test_core_utils.py +190 -0
  177. atlas/tests/test_docker_env_sync.py +202 -0
  178. atlas/tests/test_domain_errors.py +329 -0
  179. atlas/tests/test_domain_whitelist.py +359 -0
  180. atlas/tests/test_elicitation_manager.py +408 -0
  181. atlas/tests/test_elicitation_routing.py +296 -0
  182. atlas/tests/test_env_demo_server.py +88 -0
  183. atlas/tests/test_error_classification.py +113 -0
  184. atlas/tests/test_error_flow_integration.py +116 -0
  185. atlas/tests/test_feedback_routes.py +333 -0
  186. atlas/tests/test_file_content_extraction.py +1134 -0
  187. atlas/tests/test_file_extraction_routes.py +158 -0
  188. atlas/tests/test_file_library.py +107 -0
  189. atlas/tests/test_file_manager_unit.py +18 -0
  190. atlas/tests/test_health_route.py +49 -0
  191. atlas/tests/test_http_client_stub.py +8 -0
  192. atlas/tests/test_imports_smoke.py +30 -0
  193. atlas/tests/test_interfaces_llm_response.py +9 -0
  194. atlas/tests/test_issue_access_denied_fix.py +136 -0
  195. atlas/tests/test_llm_env_expansion.py +836 -0
  196. atlas/tests/test_log_level_sensitive_data.py +285 -0
  197. atlas/tests/test_mcp_auth_routes.py +341 -0
  198. atlas/tests/test_mcp_client_auth.py +331 -0
  199. atlas/tests/test_mcp_data_injection.py +270 -0
  200. atlas/tests/test_mcp_get_authorized_servers.py +95 -0
  201. atlas/tests/test_mcp_hot_reload.py +512 -0
  202. atlas/tests/test_mcp_image_content.py +424 -0
  203. atlas/tests/test_mcp_logging.py +172 -0
  204. atlas/tests/test_mcp_progress_updates.py +313 -0
  205. atlas/tests/test_mcp_prompt_override_system_prompt.py +102 -0
  206. atlas/tests/test_mcp_prompts_server.py +39 -0
  207. atlas/tests/test_mcp_tool_result_parsing.py +296 -0
  208. atlas/tests/test_metrics_logger.py +56 -0
  209. atlas/tests/test_middleware_auth.py +379 -0
  210. atlas/tests/test_prompt_risk_and_acl.py +141 -0
  211. atlas/tests/test_rag_mcp_aggregator.py +204 -0
  212. atlas/tests/test_rag_mcp_service.py +224 -0
  213. atlas/tests/test_rate_limit_middleware.py +45 -0
  214. atlas/tests/test_routes_config_smoke.py +60 -0
  215. atlas/tests/test_routes_files_download_token.py +41 -0
  216. atlas/tests/test_routes_files_health.py +18 -0
  217. atlas/tests/test_runtime_imports.py +53 -0
  218. atlas/tests/test_sampling_integration.py +482 -0
  219. atlas/tests/test_security_admin_routes.py +61 -0
  220. atlas/tests/test_security_capability_tokens.py +65 -0
  221. atlas/tests/test_security_file_stats_scope.py +21 -0
  222. atlas/tests/test_security_header_injection.py +191 -0
  223. atlas/tests/test_security_headers_and_filename.py +63 -0
  224. atlas/tests/test_shared_session_repository.py +101 -0
  225. atlas/tests/test_system_prompt_loading.py +181 -0
  226. atlas/tests/test_token_storage.py +505 -0
  227. atlas/tests/test_tool_approval_config.py +93 -0
  228. atlas/tests/test_tool_approval_utils.py +356 -0
  229. atlas/tests/test_tool_authorization_group_filtering.py +223 -0
  230. atlas/tests/test_tool_details_in_config.py +108 -0
  231. atlas/tests/test_tool_planner.py +300 -0
  232. atlas/tests/test_unified_rag_service.py +398 -0
  233. atlas/tests/test_username_override_in_approval.py +258 -0
  234. atlas/tests/test_websocket_auth_header.py +168 -0
  235. atlas/version.py +6 -0
  236. atlas_chat-0.1.0.data/data/.env.example +253 -0
  237. atlas_chat-0.1.0.data/data/config/defaults/compliance-levels.json +44 -0
  238. atlas_chat-0.1.0.data/data/config/defaults/domain-whitelist.json +123 -0
  239. atlas_chat-0.1.0.data/data/config/defaults/file-extractors.json +74 -0
  240. atlas_chat-0.1.0.data/data/config/defaults/help-config.json +198 -0
  241. atlas_chat-0.1.0.data/data/config/defaults/llmconfig-buggy.yml +11 -0
  242. atlas_chat-0.1.0.data/data/config/defaults/llmconfig.yml +19 -0
  243. atlas_chat-0.1.0.data/data/config/defaults/mcp.json +138 -0
  244. atlas_chat-0.1.0.data/data/config/defaults/rag-sources.json +17 -0
  245. atlas_chat-0.1.0.data/data/config/defaults/splash-config.json +16 -0
  246. atlas_chat-0.1.0.dist-info/METADATA +236 -0
  247. atlas_chat-0.1.0.dist-info/RECORD +250 -0
  248. atlas_chat-0.1.0.dist-info/WHEEL +5 -0
  249. atlas_chat-0.1.0.dist-info/entry_points.txt +4 -0
  250. atlas_chat-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,865 @@
1
+ """Admin routes for configuration management and system monitoring.
2
+
3
+ Provides admin-only endpoints for: banners, configuration files, logs, and (commented) health checks.
4
+ """
5
+
6
+ import json
7
+ import logging
8
+ import os
9
+ import shutil
10
+ import time
11
+ from pathlib import Path
12
+ from typing import Any, Dict, List, Optional
13
+
14
+ import yaml
15
+ from fastapi import APIRouter, Depends, HTTPException
16
+ from fastapi.responses import FileResponse
17
+ from pydantic import BaseModel
18
+
19
+ from atlas.core.auth import is_user_in_group
20
+ from atlas.core.log_sanitizer import get_current_user, sanitize_for_logging
21
+ from atlas.infrastructure.app_factory import app_factory
22
+ from atlas.modules.config import config_manager
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ admin_router = APIRouter(prefix="/admin", tags=["admin"])
27
+
28
+
29
+ class AdminConfigUpdate(BaseModel):
30
+ content: str
31
+ file_type: str # 'json', 'yaml', 'text'
32
+
33
+
34
+ class BannerMessageUpdate(BaseModel):
35
+ messages: List[str]
36
+
37
+
38
+ class MCPServerAction(BaseModel):
39
+ server_name: str
40
+
41
+
42
+ async def require_admin(current_user: str = Depends(get_current_user)) -> str:
43
+ admin_group = config_manager.app_settings.admin_group
44
+ if not await is_user_in_group(current_user, admin_group):
45
+ raise HTTPException(
46
+ status_code=403,
47
+ detail=f"Admin access required. User must be in '{admin_group}' group.",
48
+ )
49
+ return current_user
50
+
51
+
52
+ def setup_config_overrides() -> None:
53
+ """Ensure editable overrides directory exists; seed from defaults / legacy if empty."""
54
+ app_settings = config_manager.app_settings
55
+ overrides_root = Path(app_settings.app_config_overrides)
56
+ defaults_root = Path(app_settings.app_config_defaults)
57
+
58
+ # If relative paths, resolve from project root
59
+ if not overrides_root.is_absolute():
60
+ project_root = Path(__file__).parent.parent.parent
61
+ overrides_root = project_root / overrides_root
62
+ if not defaults_root.is_absolute():
63
+ project_root = Path(__file__).parent.parent.parent
64
+ defaults_root = project_root / defaults_root
65
+
66
+ overrides_root.mkdir(parents=True, exist_ok=True)
67
+ defaults_root.mkdir(parents=True, exist_ok=True)
68
+
69
+ if any(overrides_root.iterdir()):
70
+ return
71
+
72
+ logger.info("Seeding empty overrides directory")
73
+ seed_sources = [
74
+ defaults_root,
75
+ Path("backend/configfilesadmin"),
76
+ Path("backend/configfiles"),
77
+ Path("configfilesadmin"),
78
+ Path("configfiles"),
79
+ ]
80
+ for source in seed_sources:
81
+ if source.exists() and any(source.iterdir()):
82
+ for file_path in source.glob("*"):
83
+ if file_path.is_file():
84
+ dest = overrides_root / file_path.name
85
+ try:
86
+ shutil.copy2(file_path, dest)
87
+ logger.info(f"Copied seed config {sanitize_for_logging(str(file_path))} -> {sanitize_for_logging(str(dest))}")
88
+ except Exception as e: # noqa: BLE001
89
+ logger.error(f"Failed seeding {sanitize_for_logging(str(file_path))}: {e}")
90
+ break
91
+
92
+
93
+ def get_admin_config_path(filename: str) -> Path:
94
+ # Get config filename mappings from config manager
95
+ app_settings = config_manager.app_settings
96
+
97
+ # Map standard filenames to potentially overridden ones
98
+ if filename == "messages.txt":
99
+ custom_filename = app_settings.messages_config_file
100
+ elif filename == "help-config.json":
101
+ custom_filename = app_settings.help_config_file
102
+ elif filename == "mcp.json":
103
+ custom_filename = app_settings.mcp_config_file
104
+ elif filename == "llmconfig.yml":
105
+ custom_filename = app_settings.llm_config_file
106
+ else:
107
+ custom_filename = filename
108
+
109
+ # Use same logic as config manager to resolve relative paths from project root
110
+ base = Path(app_settings.app_config_overrides)
111
+
112
+ # If relative path, resolve from project root (parent of backend directory)
113
+ if not base.is_absolute():
114
+ project_root = Path(__file__).parent.parent.parent # Go up from atlas.routes/ to backend/ to project root
115
+ base = project_root / base
116
+
117
+ base.mkdir(parents=True, exist_ok=True)
118
+ return base / custom_filename
119
+
120
+
121
+ def get_file_content(file_path: Path) -> str:
122
+ if not file_path.exists():
123
+ raise HTTPException(status_code=404, detail=f"File {file_path.name} not found")
124
+ try:
125
+ with open(file_path, "r", encoding="utf-8") as f:
126
+ return f.read()
127
+ except UnicodeDecodeError:
128
+ with open(file_path, "r", encoding="utf-8", errors="replace") as f:
129
+ return f.read()
130
+ except Exception as e: # noqa: BLE001
131
+ logger.error(f"Error reading file {file_path}: {e}")
132
+ raise HTTPException(status_code=500, detail=f"Error reading file: {e}")
133
+
134
+
135
+ def write_file_content(file_path: Path, content: str, file_type: str = "text") -> None:
136
+ try:
137
+ if file_type == "json":
138
+ json.loads(content)
139
+ elif file_type == "yaml":
140
+ yaml.safe_load(content)
141
+
142
+ temp_path = file_path.with_suffix(file_path.suffix + ".tmp")
143
+ if temp_path.exists():
144
+ temp_path.unlink()
145
+ with open(temp_path, "w", encoding="utf-8") as f:
146
+ f.write(content)
147
+ if os.name == "nt" and file_path.exists(): # Windows atomic rename safety
148
+ file_path.unlink()
149
+ temp_path.rename(file_path)
150
+ logger.info(f"Updated config file {file_path}")
151
+ except (json.JSONDecodeError, yaml.YAMLError) as e:
152
+ raise HTTPException(status_code=400, detail=f"Invalid {file_type.upper()}: {e}")
153
+ except Exception as e: # noqa: BLE001
154
+ logger.error(f"Error writing file {file_path}: {e}")
155
+ raise HTTPException(status_code=500, detail=f"Error writing file: {e}")
156
+
157
+
158
+ def _project_root() -> Path:
159
+ # routes/admin_routes.py -> backend/routes -> project root is 2 levels up
160
+ return Path(__file__).resolve().parents[2]
161
+
162
+
163
+ def _log_base_dir() -> Path:
164
+ app_settings = config_manager.app_settings
165
+ if app_settings.app_log_dir:
166
+ return Path(app_settings.app_log_dir)
167
+ return _project_root() / "logs"
168
+
169
+
170
+ def _locate_log_file() -> Path:
171
+ """Locate the log file (standardized on project_root/logs with optional override).
172
+
173
+ Priority:
174
+ 1. APP_LOG_DIR (env) if set
175
+ 2. ./logs
176
+ 3. Legacy fallbacks (backend/logs, runtime/logs) for backward compatibility
177
+ """
178
+ base = _log_base_dir()
179
+ candidates = [
180
+ base / "app.jsonl",
181
+ base / "app.log",
182
+ Path("logs/app.jsonl"),
183
+ Path("logs/app.log"),
184
+ Path("backend/logs/app.jsonl"), # legacy
185
+ Path("backend/logs/app.log"), # legacy
186
+ Path("runtime/logs/app.jsonl"), # legacy
187
+ Path("runtime/logs/app.log"), # legacy
188
+ ]
189
+ for c in candidates:
190
+ if c.exists():
191
+ return c
192
+ raise HTTPException(status_code=404, detail="Log file not found")
193
+
194
+
195
+ @admin_router.get("/")
196
+ async def admin_dashboard(admin_user: str = Depends(require_admin)):
197
+ return {
198
+ "message": "Admin Dashboard",
199
+ "user": admin_user,
200
+ "available_endpoints": [
201
+ "/admin/banners",
202
+ "/admin/logs/viewer",
203
+ "/admin/logs/clear",
204
+ "/admin/logs/download",
205
+ "/admin/mcp/reload",
206
+ "/admin/mcp/reconnect",
207
+ "/admin/mcp/status",
208
+ ],
209
+ }
210
+
211
+
212
+ @admin_router.get("/banners")
213
+ async def get_banner_config(admin_user: str = Depends(require_admin)):
214
+ try:
215
+ setup_config_overrides()
216
+ messages_file = get_admin_config_path("messages.txt")
217
+ if not messages_file.exists():
218
+ write_file_content(messages_file, "System status: All services operational\n")
219
+ content = get_file_content(messages_file)
220
+ messages = [ln.strip() for ln in content.splitlines() if ln.strip()]
221
+ return {
222
+ "messages": messages,
223
+ "file_path": str(messages_file),
224
+ "last_modified": messages_file.stat().st_mtime,
225
+ "banner_enabled": config_manager.app_settings.banner_enabled,
226
+ }
227
+ except Exception as e: # noqa: BLE001
228
+ logger.error(f"Error getting banner config: {e}")
229
+ raise HTTPException(status_code=500, detail=str(e))
230
+
231
+
232
+ @admin_router.post("/banners")
233
+ async def update_banner_config(
234
+ update: BannerMessageUpdate, admin_user: str = Depends(require_admin)
235
+ ):
236
+ messages_file = None
237
+ try:
238
+ setup_config_overrides()
239
+ messages_file = get_admin_config_path("messages.txt")
240
+ content = ("\n".join(update.messages) + "\n") if update.messages else ""
241
+ write_file_content(messages_file, content)
242
+ logger.info(
243
+ f"Banner messages successfully saved to disk at {sanitize_for_logging(str(messages_file))} "
244
+ f"by {sanitize_for_logging(admin_user)}"
245
+ )
246
+ return {
247
+ "message": "Banner messages updated successfully",
248
+ "messages": update.messages,
249
+ "updated_by": admin_user,
250
+ }
251
+ except Exception as e: # noqa: BLE001
252
+ file_path_str = sanitize_for_logging(str(messages_file)) if messages_file else "unknown path"
253
+ logger.error(
254
+ f"Failed to save banner messages to disk at {file_path_str}: {e}"
255
+ )
256
+ raise HTTPException(status_code=500, detail=str(e))
257
+
258
+
259
+ @admin_router.post("/mcp/reload")
260
+ async def reload_mcp_servers(admin_user: str = Depends(require_admin)):
261
+ """Reload MCP servers from disk configuration and reinitialize connections.
262
+
263
+ This endpoint:
264
+ 1. Reloads the mcp.json configuration from disk (hot-reload)
265
+ 2. Reinitializes all MCP client connections
266
+ 3. Rediscovers tools and prompts from all servers
267
+
268
+ Use this after modifying the mcp.json configuration file to apply changes
269
+ without restarting the application.
270
+ """
271
+ try:
272
+ mcp = app_factory.get_mcp_manager()
273
+
274
+ # Reload config from disk first
275
+ config_changes = mcp.reload_config()
276
+
277
+ # Re-initialize clients and rediscover
278
+ await mcp.initialize_clients()
279
+ await mcp.discover_tools()
280
+ await mcp.discover_prompts()
281
+
282
+ return {
283
+ "message": "MCP servers reloaded from disk configuration",
284
+ "config_changes": config_changes,
285
+ "servers": list(mcp.clients.keys()),
286
+ "failed_servers": list(mcp.get_failed_servers().keys()),
287
+ "tool_counts": {k: len(v.get("tools", [])) for k, v in mcp.available_tools.items()},
288
+ "prompt_counts": {k: len(v.get("prompts", [])) for k, v in mcp.available_prompts.items()},
289
+ "reloaded_by": admin_user,
290
+ }
291
+ except Exception as e: # noqa: BLE001
292
+ logger.error(f"Error reloading MCP servers: {e}", exc_info=True)
293
+ raise HTTPException(status_code=500, detail=str(e))
294
+
295
+
296
+ @admin_router.post("/mcp/reconnect")
297
+ async def reconnect_failed_mcp_servers(admin_user: str = Depends(require_admin)):
298
+ """Attempt to reconnect to MCP servers that previously failed.
299
+
300
+ This endpoint manually triggers reconnection attempts for servers that failed
301
+ to connect during initialization or previous reconnection attempts.
302
+ Respects exponential backoff unless force=true is specified.
303
+ """
304
+ try:
305
+ mcp = app_factory.get_mcp_manager()
306
+ # Admin-triggered reconnect should bypass backoff and try immediately
307
+ result = await mcp.reconnect_failed_servers(force=True)
308
+
309
+ return {
310
+ "message": "Reconnection attempt completed",
311
+ "result": result,
312
+ "current_servers": list(mcp.clients.keys()),
313
+ "failed_servers": mcp.get_failed_servers(),
314
+ "triggered_by": admin_user,
315
+ }
316
+ except Exception as e: # noqa: BLE001
317
+ logger.error(f"Error reconnecting MCP servers: {e}", exc_info=True)
318
+ raise HTTPException(status_code=500, detail=str(e))
319
+
320
+
321
+ @admin_router.get("/mcp/status")
322
+ async def get_mcp_status(admin_user: str = Depends(require_admin)):
323
+ """Get current MCP server connection status.
324
+
325
+ Returns information about:
326
+ - Currently connected servers
327
+ - Failed servers with error details and backoff info
328
+ - Auto-reconnect feature status
329
+ """
330
+ try:
331
+ mcp = app_factory.get_mcp_manager()
332
+ app_settings = config_manager.app_settings
333
+
334
+ failed_servers = mcp.get_failed_servers()
335
+
336
+ # Calculate next retry time for each failed server
337
+ current_time = time.time()
338
+ failed_servers_with_timing = {}
339
+ for server_name, failure_info in failed_servers.items():
340
+ backoff_delay = mcp._calculate_backoff_delay(failure_info["attempt_count"])
341
+ time_since_last = current_time - failure_info["last_attempt"]
342
+ next_retry_in = max(0, backoff_delay - time_since_last)
343
+
344
+ failed_servers_with_timing[server_name] = {
345
+ **failure_info,
346
+ "backoff_delay": backoff_delay,
347
+ "next_retry_in_seconds": next_retry_in,
348
+ }
349
+
350
+ # A server is considered "connected" only if it has a client AND
351
+ # at least one tool or prompt discovered (or explicitly marked as
352
+ # having zero tools/prompts but no recorded failure). This prevents
353
+ # HTTP/SSE/SSL discovery failures from showing as connected.
354
+ connected_servers: List[str] = []
355
+ for server_name in mcp.clients.keys():
356
+ tools = mcp.available_tools.get(server_name, {}).get("tools", [])
357
+ prompts = mcp.available_prompts.get(server_name, {}).get("prompts", [])
358
+ if tools or prompts:
359
+ connected_servers.append(server_name)
360
+ elif server_name not in failed_servers_with_timing:
361
+ # No tools/prompts but also no recorded failure; treat as connected
362
+ # to preserve behavior for servers that legitimately expose nothing.
363
+ connected_servers.append(server_name)
364
+
365
+ return {
366
+ "connected_servers": connected_servers,
367
+ "configured_servers": list(mcp.servers_config.keys()),
368
+ "failed_servers": failed_servers_with_timing,
369
+ "auto_reconnect": {
370
+ "enabled": app_settings.feature_mcp_auto_reconnect_enabled,
371
+ "base_interval": app_settings.mcp_reconnect_interval,
372
+ "max_interval": app_settings.mcp_reconnect_max_interval,
373
+ "backoff_multiplier": app_settings.mcp_reconnect_backoff_multiplier,
374
+ "running": mcp._reconnect_running,
375
+ },
376
+ "tool_counts": {k: len(v.get("tools", [])) for k, v in mcp.available_tools.items()},
377
+ "prompt_counts": {k: len(v.get("prompts", [])) for k, v in mcp.available_prompts.items()},
378
+ }
379
+ except Exception as e: # noqa: BLE001
380
+ logger.error(f"Error getting MCP status: {e}", exc_info=True)
381
+ raise HTTPException(status_code=500, detail=str(e))
382
+
383
+
384
+
385
+ # --- Config Viewer ---
386
+ @admin_router.get("/config/view")
387
+ async def get_all_configs(admin_user: str = Depends(require_admin)):
388
+ """Get all configuration values for admin viewing."""
389
+ try:
390
+ # Get all configs from config manager
391
+ app_settings = config_manager.app_settings
392
+ llm_config = config_manager.llm_config
393
+ mcp_config = config_manager.mcp_config
394
+
395
+ # Convert app_settings to dict, excluding sensitive fields
396
+ app_settings_dict = app_settings.model_dump()
397
+
398
+ # Mask sensitive fields
399
+ sensitive_fields = ['api_key', 'secret', 'password', 'token']
400
+ for key, value in app_settings_dict.items():
401
+ if any(sensitive in key.lower() for sensitive in sensitive_fields):
402
+ if isinstance(value, str) and value:
403
+ app_settings_dict[key] = "***MASKED***"
404
+
405
+ # Convert LLM config, masking API keys
406
+ llm_config_dict = llm_config.model_dump()
407
+ if 'models' in llm_config_dict:
408
+ for model_name, model_config in llm_config_dict['models'].items():
409
+ if 'api_key' in model_config and model_config['api_key']:
410
+ model_config['api_key'] = "***MASKED***"
411
+
412
+ # Convert MCP config
413
+ mcp_config_dict = mcp_config.model_dump()
414
+
415
+ return {
416
+ "app_settings": app_settings_dict,
417
+ "llm_config": llm_config_dict,
418
+ "mcp_config": mcp_config_dict,
419
+ "config_validation": config_manager.validate_config()
420
+ }
421
+ except Exception as e:
422
+ logger.error(f"Error getting config view: {e}")
423
+ raise HTTPException(status_code=500, detail=str(e))
424
+
425
+
426
+ # --- Log Management ---
427
+
428
+ @admin_router.get("/logs/viewer")
429
+ async def get_enhanced_logs(
430
+ lines: int = 500,
431
+ level_filter: Optional[str] = None,
432
+ module_filter: Optional[str] = None,
433
+ admin_user: str = Depends(require_admin), # noqa: ARG001 (enforces auth)
434
+ ):
435
+ try:
436
+ base_dir = _log_base_dir()
437
+ log_file = base_dir / "app.jsonl"
438
+ if not log_file.exists():
439
+ print(f"Log file {log_file.absolute()} not found")
440
+ raise HTTPException(status_code=404, detail="Log file not found")
441
+
442
+ from collections import deque
443
+ entries: List[Dict[str, Any]] = []
444
+ modules: set[str] = set()
445
+ levels: set[str] = set()
446
+
447
+ try:
448
+ with log_file.open("r", encoding="utf-8") as f:
449
+ recent_lines = deque(f, maxlen=lines + 200)
450
+ import re
451
+ pattern = re.compile(
452
+ r"(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})[,\s-]*(\w+)[,\s-]*([^-]*)[,\s-]*(.*)"
453
+ )
454
+ for raw in recent_lines:
455
+ raw = raw.strip()
456
+ if not raw or raw == "NEW LOG":
457
+ continue
458
+ try:
459
+ entry = json.loads(raw)
460
+ processed = {
461
+ "timestamp": entry.get("timestamp", ""),
462
+ "level": entry.get("level", "UNKNOWN"),
463
+ "module": entry.get("module", entry.get("logger", "")),
464
+ "logger": entry.get("logger", ""),
465
+ "function": entry.get("function", ""),
466
+ "message": entry.get("message", ""),
467
+ "trace_id": entry.get("trace_id", ""),
468
+ "span_id": entry.get("span_id", ""),
469
+ "line": entry.get("line", ""),
470
+ "thread_name": entry.get("thread_name", ""),
471
+ "extras": {k: v for k, v in entry.items() if k.startswith("extra_")},
472
+ }
473
+ except json.JSONDecodeError:
474
+ m = pattern.match(raw)
475
+ if m:
476
+ ts, lvl, mod, msg = m.groups()
477
+ processed = {
478
+ "timestamp": ts.strip(),
479
+ "level": lvl.strip().upper(),
480
+ "module": mod.strip(),
481
+ "logger": mod.strip(),
482
+ "function": "",
483
+ "message": msg.strip(),
484
+ "trace_id": "",
485
+ "span_id": "",
486
+ "line": "",
487
+ "thread_name": "",
488
+ "extras": {},
489
+ }
490
+ else:
491
+ processed = {
492
+ "timestamp": "",
493
+ "level": "INFO",
494
+ "module": "unknown",
495
+ "logger": "unknown",
496
+ "function": "",
497
+ "message": raw,
498
+ "trace_id": "",
499
+ "span_id": "",
500
+ "line": "",
501
+ "thread_name": "",
502
+ "extras": {},
503
+ }
504
+ if level_filter and processed["level"] != level_filter:
505
+ continue
506
+ if module_filter and processed["module"] != module_filter:
507
+ continue
508
+ entries.append(processed)
509
+ modules.add(processed["module"])
510
+ levels.add(processed["level"])
511
+ if len(entries) >= lines:
512
+ break
513
+ except Exception as e: # noqa: BLE001
514
+ logger.error(f"Error reading log file {log_file}: {e}")
515
+ entries = [
516
+ {
517
+ "timestamp": "",
518
+ "level": "ERROR",
519
+ "module": "admin",
520
+ "logger": "admin",
521
+ "function": "get_enhanced_logs",
522
+ "message": "An internal error occurred while reading log file.",
523
+ "trace_id": "",
524
+ "span_id": "",
525
+ "line": "",
526
+ "thread_name": "",
527
+ "extras": {},
528
+ }
529
+ ]
530
+ modules = {"admin"}
531
+ levels = {"ERROR"}
532
+
533
+ return {
534
+ "entries": entries,
535
+ "metadata": {
536
+ "total_entries": len(entries),
537
+ "unique_modules": sorted(modules),
538
+ "unique_levels": sorted(levels),
539
+ "log_file_path": str(log_file),
540
+ "requested_lines": lines,
541
+ "filters_applied": {"level": level_filter, "module": module_filter},
542
+ },
543
+ }
544
+ except Exception as e: # noqa: BLE001
545
+ logger.error(f"Error getting enhanced logs: {e}")
546
+ raise HTTPException(status_code=500, detail=str(e))
547
+
548
+
549
+ @admin_router.post("/logs/clear")
550
+ async def clear_app_logs(admin_user: str = Depends(require_admin)):
551
+ try:
552
+ base = _log_base_dir()
553
+ candidates = [
554
+ base / "app.jsonl",
555
+ base / "app.log",
556
+ Path("logs/app.jsonl"), # explicit root fallback
557
+ Path("logs/app.log"), # explicit root fallback
558
+ Path("backend/logs/app.jsonl"), # legacy
559
+ Path("backend/logs/app.log"), # legacy
560
+ Path("runtime/logs/app.jsonl"), # legacy
561
+ Path("runtime/logs/app.log"), # legacy,
562
+ ]
563
+ cleared: List[str] = []
564
+ for f in candidates:
565
+ if f.exists():
566
+ try:
567
+ f.write_text("NEW LOG\n", encoding="utf-8")
568
+ cleared.append(str(f))
569
+ except Exception as e: # noqa: BLE001
570
+ logger.error(f"Failed clearing {f}: {e}")
571
+ if not cleared:
572
+ return {"message": "No log files found to clear", "cleared_by": admin_user, "files_cleared": []}
573
+ sanitized_admin_user = sanitize_for_logging(admin_user)
574
+ logger.info(f"Log files cleared by {sanitized_admin_user}: {cleared}")
575
+ return {"message": "Log files cleared successfully", "cleared_by": admin_user, "files_cleared": cleared}
576
+ except Exception as e: # noqa: BLE001
577
+ logger.error(f"Error clearing logs: {e}")
578
+ raise HTTPException(status_code=500, detail=str(e))
579
+
580
+
581
+ @admin_router.get("/logs/download")
582
+ async def download_logs(admin_user: str = Depends(require_admin)):
583
+ """Download the raw application log file.
584
+
585
+ Frontend sets a custom filename via the anchor `download` attribute, so we just
586
+ stream the file with a generic name. Uses same discovery logic as log viewer.
587
+ """
588
+ try:
589
+ log_file = _locate_log_file()
590
+ # Choose media type: jsonl logs are still plain text; no compression here.
591
+ media_type = "application/json" if log_file.suffix == ".jsonl" else "text/plain"
592
+ return FileResponse(
593
+ path=str(log_file),
594
+ media_type=media_type,
595
+ filename=log_file.name,
596
+ )
597
+ except HTTPException:
598
+ raise
599
+ except Exception as e: # noqa: BLE001
600
+ logger.error(f"Error preparing log download: {e}")
601
+ raise HTTPException(status_code=500, detail="Error preparing log download")
602
+
603
+
604
+ # --- System Status (minimal) ---
605
+
606
+ @admin_router.get("/system-status")
607
+ async def get_system_status(admin_user: str = Depends(require_admin)):
608
+ """Minimal system status endpoint for the Admin UI.
609
+
610
+ Returns basic configuration and logging status; avoids heavy checks.
611
+ """
612
+ try:
613
+ # Configuration status: overrides directory and file count
614
+ app_settings = config_manager.app_settings
615
+ overrides_root = Path(app_settings.app_config_overrides)
616
+ if not overrides_root.is_absolute():
617
+ project_root = _project_root()
618
+ overrides_root = project_root / overrides_root
619
+ overrides_root.mkdir(parents=True, exist_ok=True)
620
+ config_files = list(overrides_root.glob("*"))
621
+ config_status = "healthy" if config_files else "warning"
622
+
623
+ # Logging status
624
+ log_dir = _log_base_dir()
625
+ log_file = log_dir / "app.jsonl"
626
+ log_exists = log_file.exists()
627
+ logging_status = "healthy" if log_exists else "warning"
628
+
629
+ components = [
630
+ {
631
+ "component": "Configuration",
632
+ "status": config_status,
633
+ "details": {
634
+ "overrides_dir": str(overrides_root),
635
+ "files_count": len(config_files),
636
+ },
637
+ },
638
+ {
639
+ "component": "Logging",
640
+ "status": logging_status,
641
+ "details": {
642
+ "log_file": str(log_file),
643
+ "exists": log_exists,
644
+ "size_bytes": log_file.stat().st_size if log_exists else 0,
645
+ },
646
+ },
647
+ ]
648
+
649
+ overall = "healthy" if all(c["status"] == "healthy" for c in components) else "warning"
650
+ return {
651
+ "overall_status": overall,
652
+ "components": components,
653
+ "checked_by": admin_user,
654
+ }
655
+ except Exception as e: # noqa: BLE001
656
+ logger.error(f"Error getting system status: {e}")
657
+ raise HTTPException(status_code=500, detail=str(e))
658
+
659
+
660
+ # --- MCP Server Management ---
661
+
662
+ @admin_router.get("/mcp/available-servers")
663
+ async def get_available_mcp_servers(
664
+ admin_user: str = Depends(require_admin), # noqa: ARG001 (enforces auth)
665
+ ):
666
+ """Get all available MCP servers from the example-configs directory."""
667
+ try:
668
+ project_root = _project_root()
669
+ example_configs_dir = project_root / "config" / "mcp-example-configs"
670
+
671
+ if not example_configs_dir.exists():
672
+ return {"available_servers": {}}
673
+
674
+ available_servers = {}
675
+
676
+ for config_file in example_configs_dir.glob("mcp-*.json"):
677
+ try:
678
+ with config_file.open("r", encoding="utf-8") as f:
679
+ config_data = json.load(f)
680
+
681
+ # Each file should contain one server config
682
+ for server_name, server_config in config_data.items():
683
+ available_servers[server_name] = {
684
+ "config": server_config,
685
+ "source_file": config_file.name,
686
+ "description": server_config.get("description", ""),
687
+ "short_description": server_config.get("short_description", ""),
688
+ "author": server_config.get("author", ""),
689
+ "compliance_level": server_config.get("compliance_level", "")
690
+ }
691
+ except (json.JSONDecodeError, Exception) as e:
692
+ logger.warning(f"Failed to parse {config_file.name}: {e}")
693
+ continue
694
+
695
+ return {"available_servers": available_servers}
696
+
697
+ except Exception as e:
698
+ logger.error(f"Error getting available MCP servers: {e}")
699
+ raise HTTPException(status_code=500, detail=str(e))
700
+
701
+
702
+ @admin_router.get("/mcp/active-servers")
703
+ async def get_active_mcp_servers(
704
+ admin_user: str = Depends(require_admin), # noqa: ARG001 (enforces auth)
705
+ ):
706
+ """Get currently active MCP servers from the overrides/mcp.json file."""
707
+ try:
708
+ mcp_config_path = get_admin_config_path("mcp.json")
709
+
710
+ if not mcp_config_path.exists():
711
+ return {"active_servers": {}}
712
+
713
+ with mcp_config_path.open("r", encoding="utf-8") as f:
714
+ active_config = json.load(f)
715
+
716
+ return {"active_servers": active_config}
717
+
718
+ except Exception as e:
719
+ logger.error(f"Error getting active MCP servers: {e}")
720
+ raise HTTPException(status_code=500, detail=str(e))
721
+
722
+
723
+ @admin_router.post("/mcp/add-server")
724
+ async def add_mcp_server(
725
+ action: MCPServerAction,
726
+ admin_user: str = Depends(require_admin), # noqa: ARG001 (enforces auth)
727
+ ):
728
+ """Add an MCP server from example-configs to the active configuration."""
729
+ try:
730
+ server_name = action.server_name
731
+
732
+ # Get the server config from example-configs
733
+ project_root = _project_root()
734
+ example_configs_dir = project_root / "config" / "mcp-example-configs"
735
+
736
+ server_config = None
737
+ for config_file in example_configs_dir.glob("mcp-*.json"):
738
+ try:
739
+ with config_file.open("r", encoding="utf-8") as f:
740
+ config_data = json.load(f)
741
+
742
+ if server_name in config_data:
743
+ server_config = config_data[server_name]
744
+ break
745
+ except (json.JSONDecodeError, Exception):
746
+ continue
747
+
748
+ if not server_config:
749
+ raise HTTPException(
750
+ status_code=404,
751
+ detail=f"Server '{server_name}' not found in example configurations"
752
+ )
753
+
754
+ # Load current active configuration
755
+ mcp_config_path = get_admin_config_path("mcp.json")
756
+
757
+ if mcp_config_path.exists():
758
+ with mcp_config_path.open("r", encoding="utf-8") as f:
759
+ active_config = json.load(f)
760
+ else:
761
+ active_config = {}
762
+
763
+ # Check if server is already active
764
+ if server_name in active_config:
765
+ return {
766
+ "message": f"Server '{server_name}' is already active",
767
+ "server_name": server_name,
768
+ "already_active": True
769
+ }
770
+
771
+ # Add the server to active configuration
772
+ active_config[server_name] = server_config
773
+
774
+ # Save the updated configuration
775
+ with mcp_config_path.open("w", encoding="utf-8") as f:
776
+ json.dump(active_config, f, indent=2)
777
+
778
+ sanitized_admin_user = sanitize_for_logging(admin_user)
779
+ sanitized_server_name = sanitize_for_logging(server_name)
780
+ logger.info(f"Admin {sanitized_admin_user} added MCP server '{sanitized_server_name}' to active configuration")
781
+
782
+ # Trigger MCP reload to apply changes
783
+ try:
784
+ mcp_manager = app_factory.get_mcp_manager()
785
+ if mcp_manager:
786
+ await mcp_manager.reload_servers()
787
+ except Exception as reload_error:
788
+ sanitized_server_name = sanitize_for_logging(server_name)
789
+ logger.warning(f"Failed to reload MCP servers after adding '{sanitized_server_name}': {reload_error}")
790
+
791
+ return {
792
+ "message": f"Server '{server_name}' added successfully",
793
+ "server_name": server_name,
794
+ "config": server_config
795
+ }
796
+
797
+ except HTTPException:
798
+ raise
799
+ except Exception as e:
800
+ sanitized_server_name = sanitize_for_logging(action.server_name)
801
+ logger.error(f"Error adding MCP server '{sanitized_server_name}': {e}")
802
+ raise HTTPException(status_code=500, detail=str(e))
803
+
804
+
805
+ @admin_router.post("/mcp/remove-server")
806
+ async def remove_mcp_server(
807
+ action: MCPServerAction,
808
+ admin_user: str = Depends(require_admin), # noqa: ARG001 (enforces auth)
809
+ ):
810
+ """Remove an MCP server from the active configuration."""
811
+ try:
812
+ server_name = action.server_name
813
+
814
+ # Load current active configuration
815
+ mcp_config_path = get_admin_config_path("mcp.json")
816
+
817
+ if not mcp_config_path.exists():
818
+ raise HTTPException(
819
+ status_code=404,
820
+ detail="MCP configuration file not found"
821
+ )
822
+
823
+ with mcp_config_path.open("r", encoding="utf-8") as f:
824
+ active_config = json.load(f)
825
+
826
+ # Check if server exists in active configuration
827
+ if server_name not in active_config:
828
+ return {
829
+ "message": f"Server '{server_name}' is not currently active",
830
+ "server_name": server_name,
831
+ "not_active": True
832
+ }
833
+
834
+ # Remove the server from active configuration
835
+ removed_config = active_config.pop(server_name)
836
+
837
+ # Save the updated configuration
838
+ with mcp_config_path.open("w", encoding="utf-8") as f:
839
+ json.dump(active_config, f, indent=2)
840
+
841
+ sanitized_admin_user = sanitize_for_logging(admin_user)
842
+ sanitized_server_name = sanitize_for_logging(server_name)
843
+ logger.info(f"Admin {sanitized_admin_user} removed MCP server '{sanitized_server_name}' from active configuration")
844
+
845
+ # Trigger MCP reload to apply changes
846
+ try:
847
+ mcp_manager = app_factory.get_mcp_manager()
848
+ if mcp_manager:
849
+ await mcp_manager.reload_servers()
850
+ except Exception as reload_error:
851
+ sanitized_server_name = sanitize_for_logging(server_name)
852
+ logger.warning(f"Failed to reload MCP servers after removing '{sanitized_server_name}': {reload_error}")
853
+
854
+ return {
855
+ "message": f"Server '{server_name}' removed successfully",
856
+ "server_name": server_name,
857
+ "removed_config": removed_config
858
+ }
859
+
860
+ except HTTPException:
861
+ raise
862
+ except Exception as e:
863
+ sanitized_server_name = sanitize_for_logging(action.server_name)
864
+ logger.error(f"Error removing MCP server '{sanitized_server_name}': {e}")
865
+ raise HTTPException(status_code=500, detail=str(e))