mindroot 9.3.0__py3-none-any.whl → 9.6.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 (183) hide show
  1. mindroot/coreplugins/admin/__init__.py +3 -1
  2. mindroot/coreplugins/admin/agent_router.py +250 -7
  3. mindroot/coreplugins/admin/asset_manager.py +164 -0
  4. mindroot/coreplugins/admin/command_router.py +236 -1
  5. mindroot/coreplugins/admin/mcp_catalog_routes.py +156 -0
  6. mindroot/coreplugins/admin/mcp_publish_routes.py +450 -0
  7. mindroot/coreplugins/admin/mcp_registry_routes.py +495 -0
  8. mindroot/coreplugins/admin/mcp_routes.py +216 -0
  9. mindroot/coreplugins/admin/mod.py +62 -0
  10. mindroot/coreplugins/admin/oauth_callback_router.py +84 -0
  11. mindroot/coreplugins/admin/persona_handler.py +15 -6
  12. mindroot/coreplugins/admin/persona_router.py +158 -2
  13. mindroot/coreplugins/admin/plugin_manager.py +105 -9
  14. mindroot/coreplugins/admin/plugin_router_fixed.py +23 -0
  15. mindroot/coreplugins/admin/plugin_router_new_not_working.py +145 -0
  16. mindroot/coreplugins/admin/plugin_routes.py +114 -0
  17. mindroot/coreplugins/admin/registry_settings_routes.py +140 -0
  18. mindroot/coreplugins/admin/router.py +116 -15
  19. mindroot/coreplugins/admin/service_models.py +1 -1
  20. mindroot/coreplugins/admin/settings_router.py +1 -0
  21. mindroot/coreplugins/admin/static/css/admin-custom.css +357 -2
  22. mindroot/coreplugins/admin/static/css/dark.css +1 -0
  23. mindroot/coreplugins/admin/static/css/default.css +4 -0
  24. mindroot/coreplugins/admin/static/js/about-info.js +367 -0
  25. mindroot/coreplugins/admin/static/js/agent-form.js +83 -3
  26. mindroot/coreplugins/admin/static/js/api-key-script.js +307 -0
  27. mindroot/coreplugins/admin/static/js/mcp-manager.js +348 -0
  28. mindroot/coreplugins/admin/static/js/mcp-publisher.js +780 -0
  29. mindroot/coreplugins/admin/static/js/persona-editor.js +34 -5
  30. mindroot/coreplugins/admin/static/js/plugin-toggle.js +1 -1
  31. mindroot/coreplugins/admin/static/js/recommended-plugin-install.js +63 -0
  32. mindroot/coreplugins/admin/static/js/registry-auth-section.js +132 -0
  33. mindroot/coreplugins/admin/static/js/registry-manager-base.js +613 -0
  34. mindroot/coreplugins/admin/static/js/registry-manager-publish-old-delete.js +166 -0
  35. mindroot/coreplugins/admin/static/js/registry-manager.js +351 -0
  36. mindroot/coreplugins/admin/static/js/registry-publish-section.js +377 -0
  37. mindroot/coreplugins/admin/static/js/registry-search-section.js +400 -0
  38. mindroot/coreplugins/admin/static/js/registry-search-section.js.bak +3 -0
  39. mindroot/coreplugins/admin/static/js/registry-settings.js +69 -0
  40. mindroot/coreplugins/admin/static/js/registry-shared-services.js +903 -0
  41. mindroot/coreplugins/admin/static/js/registry-simple-sections.js +85 -0
  42. mindroot/coreplugins/admin/static/js/secure-widget-manager.js +438 -0
  43. mindroot/coreplugins/admin/static/logo.png +0 -0
  44. mindroot/coreplugins/admin/templates/admin.jinja2 +275 -110
  45. mindroot/coreplugins/agent/Assistant/agent.json +27 -11
  46. mindroot/coreplugins/agent/agent.py +2 -2
  47. mindroot/coreplugins/agent/command_parser.py +25 -10
  48. mindroot/coreplugins/agent/templates/system.jinja2 +0 -12
  49. mindroot/coreplugins/chat/__init__.py +4 -1
  50. mindroot/coreplugins/chat/router.py +132 -20
  51. mindroot/coreplugins/chat/router_dedup_patch.py +20 -0
  52. mindroot/coreplugins/chat/services.py +31 -1
  53. mindroot/coreplugins/chat/static/css/action-fix.css +32 -0
  54. mindroot/coreplugins/chat/static/css/admin-custom.css +5 -3
  55. mindroot/coreplugins/chat/static/css/dark.css +24 -3
  56. mindroot/coreplugins/chat/static/css/default.css +24 -3
  57. mindroot/coreplugins/chat/static/css/main.css +1 -0
  58. mindroot/coreplugins/chat/static/js/action.js +137 -60
  59. mindroot/coreplugins/chat/static/js/chat-history.js +3 -0
  60. mindroot/coreplugins/chat/static/js/chat.js +59 -16
  61. mindroot/coreplugins/chat/static/js/chat.js.diff +221 -0
  62. mindroot/coreplugins/chat/static/js/chatform.js +2 -2
  63. mindroot/coreplugins/chat/static/site.webmanifest +1 -1
  64. mindroot/coreplugins/chat/templates/chat.jinja2 +3 -3
  65. mindroot/coreplugins/chat/widget_manager.py +139 -0
  66. mindroot/coreplugins/chat/widget_routes.py +287 -0
  67. mindroot/coreplugins/check_list/inject/admin.jinja2 +1 -1
  68. mindroot/coreplugins/email/__init__.py +2 -0
  69. mindroot/coreplugins/email/email_provider.py +2 -2
  70. mindroot/coreplugins/email/mod.py +100 -0
  71. mindroot/coreplugins/email/services.py +5 -3
  72. mindroot/coreplugins/email/smtp_handler.py +9 -3
  73. mindroot/coreplugins/email/test_email_service.py +75 -0
  74. mindroot/coreplugins/env_manager/mod.py +61 -25
  75. mindroot/coreplugins/home/router.py +37 -2
  76. mindroot/coreplugins/home/static/imgs/logo.png +0 -0
  77. mindroot/coreplugins/home/static/imgs/logo.png.bak +0 -0
  78. mindroot/coreplugins/home/static/imgs/logo_teal.png +0 -0
  79. mindroot/coreplugins/home/static/imgs/logo_teal2.png +0 -0
  80. mindroot/coreplugins/home/static/imgs/logo_teal_detailed.png +0 -0
  81. mindroot/coreplugins/home/static/imgs/logo_teal_python.png +0 -0
  82. mindroot/coreplugins/home/templates/home.jinja2 +15 -6
  83. mindroot/coreplugins/index/indices/default/index.json +39 -6
  84. mindroot/coreplugins/jwt_auth/middleware.py +47 -2
  85. mindroot/coreplugins/jwt_auth/mod.py +40 -17
  86. mindroot/coreplugins/l8n/__init__.py +6 -0
  87. mindroot/coreplugins/l8n/debug_loader.py +85 -0
  88. mindroot/coreplugins/l8n/debug_middleware.py +74 -0
  89. mindroot/coreplugins/l8n/l8n_constants.py +19 -0
  90. mindroot/coreplugins/l8n/language_detection.py +183 -0
  91. mindroot/coreplugins/l8n/middleware.py +151 -0
  92. mindroot/coreplugins/l8n/mod.py +277 -0
  93. mindroot/coreplugins/l8n/monkey_patch_to_delete.py +186 -0
  94. mindroot/coreplugins/l8n/test_enhanced.py +298 -0
  95. mindroot/coreplugins/l8n/test_l8n.py +95 -0
  96. mindroot/coreplugins/l8n/test_l8n_standalone.py +251 -0
  97. mindroot/coreplugins/l8n/test_middleware.py +272 -0
  98. mindroot/coreplugins/l8n/utils.py +232 -0
  99. mindroot/coreplugins/mcp_/__init__.py +14 -0
  100. mindroot/coreplugins/mcp_/catalog_commands.py +328 -0
  101. mindroot/coreplugins/mcp_/catalog_manager.py +263 -0
  102. mindroot/coreplugins/mcp_/dynamic_commands.py +154 -0
  103. mindroot/coreplugins/mcp_/mcp_manager.py +1031 -0
  104. mindroot/coreplugins/mcp_/mod.py +367 -0
  105. mindroot/coreplugins/mcp_/oauth_storage.py +144 -0
  106. mindroot/coreplugins/mcp_/server_installer.py +79 -0
  107. mindroot/coreplugins/mcp_/setup.py +26 -0
  108. mindroot/coreplugins/mcp_/test_dynamic_commands.py +134 -0
  109. mindroot/coreplugins/mcp_/testmcpclient.py +92 -0
  110. mindroot/coreplugins/persona/mod.py +12 -7
  111. mindroot/coreplugins/signup/templates/signup.jinja2 +1 -1
  112. mindroot/coreplugins/subscriptions/__init__.py +1 -0
  113. mindroot/coreplugins/subscriptions/mod.py +14 -3
  114. mindroot/coreplugins/subscriptions/router.py +3 -0
  115. mindroot/coreplugins/user_service/__init__.py +1 -2
  116. mindroot/coreplugins/user_service/admin_init.py +1 -0
  117. mindroot/coreplugins/user_service/email_service.py +72 -17
  118. mindroot/coreplugins/user_service/mod.py +10 -2
  119. mindroot/coreplugins/user_service/router.py +2 -0
  120. mindroot/lib/auth/api_key.py +28 -0
  121. mindroot/lib/cli/plugins.py +94 -0
  122. mindroot/lib/plugins/default_plugin_manifest.json +20 -0
  123. mindroot/lib/plugins/installation.py +5 -5
  124. mindroot/lib/plugins/l8n_static_handler.py +225 -0
  125. mindroot/lib/plugins/loader.py +33 -3
  126. mindroot/lib/plugins/loader_with_l8n.py +281 -0
  127. mindroot/lib/plugins/manifest.py +236 -24
  128. mindroot/lib/providers/commands.py +3 -1
  129. mindroot/lib/route_decorators.py +5 -5
  130. mindroot/lib/templates.py +183 -11
  131. mindroot/lib/utils/merge_arrays.py +1 -1
  132. mindroot/migrate.py +39 -20
  133. mindroot/registry/data_access.py +1 -1
  134. mindroot/server.py +42 -13
  135. mindroot/server_missing_normal_args.py +197 -0
  136. mindroot/server_prev.py +173 -0
  137. {mindroot-9.3.0.dist-info → mindroot-9.6.0.dist-info}/METADATA +7 -2
  138. {mindroot-9.3.0.dist-info → mindroot-9.6.0.dist-info}/RECORD +143 -113
  139. mindroot/coreplugins/admin/plugin_manager_backup.py +0 -615
  140. mindroot/coreplugins/admin/static/favicon/about.txt +0 -6
  141. mindroot/coreplugins/admin/static/favicon/android-chrome-512x512.png +0 -0
  142. mindroot/coreplugins/admin/static/favicon/apple-touch-icon.png +0 -0
  143. mindroot/coreplugins/admin/static/favicon/favicon-16x16.png +0 -0
  144. mindroot/coreplugins/admin/static/favicon/favicon-32x32.png +0 -0
  145. mindroot/coreplugins/admin/static/favicon/favicon.ico +0 -0
  146. mindroot/coreplugins/admin/static/favicon/favicon_io (1)/about.txt +0 -6
  147. mindroot/coreplugins/admin/static/favicon/favicon_io (1)/android-chrome-192x192.png +0 -0
  148. mindroot/coreplugins/admin/static/favicon/favicon_io (1)/android-chrome-512x512.png +0 -0
  149. mindroot/coreplugins/admin/static/favicon/favicon_io (1)/apple-touch-icon.png +0 -0
  150. mindroot/coreplugins/admin/static/favicon/favicon_io (1)/favicon-16x16.png +0 -0
  151. mindroot/coreplugins/admin/static/favicon/favicon_io (1)/favicon-32x32.png +0 -0
  152. mindroot/coreplugins/admin/static/favicon/favicon_io (1)/favicon.ico +0 -0
  153. mindroot/coreplugins/admin/static/favicon/favicon_io (1)/site.webmanifest +0 -1
  154. mindroot/coreplugins/admin/static/favicon/logo.png +0 -0
  155. mindroot/coreplugins/admin/static/favicon/site.webmanifest +0 -1
  156. mindroot/coreplugins/admin/static/js/backup/agent-editor.js +0 -186
  157. mindroot/coreplugins/admin/static/js/backup/agent-form.js +0 -1133
  158. mindroot/coreplugins/admin/static/js/backup/agent-list.js +0 -94
  159. mindroot/coreplugins/chat/static/favicon/about.txt +0 -6
  160. mindroot/coreplugins/chat/static/favicon/android-chrome-192x192.png +0 -0
  161. mindroot/coreplugins/chat/static/favicon/android-chrome-512x512.png +0 -0
  162. mindroot/coreplugins/chat/static/favicon/apple-touch-icon.png +0 -0
  163. mindroot/coreplugins/chat/static/favicon/favicon-16x16.png +0 -0
  164. mindroot/coreplugins/chat/static/favicon/favicon-32x32.png +0 -0
  165. mindroot/coreplugins/chat/static/favicon/favicon.ico +0 -0
  166. mindroot/coreplugins/chat/static/favicon/favicon_io (1)/about.txt +0 -6
  167. mindroot/coreplugins/chat/static/favicon/favicon_io (1)/android-chrome-192x192.png +0 -0
  168. mindroot/coreplugins/chat/static/favicon/favicon_io (1)/android-chrome-512x512.png +0 -0
  169. mindroot/coreplugins/chat/static/favicon/favicon_io (1)/apple-touch-icon.png +0 -0
  170. mindroot/coreplugins/chat/static/favicon/favicon_io (1)/favicon-16x16.png +0 -0
  171. mindroot/coreplugins/chat/static/favicon/favicon_io (1)/favicon-32x32.png +0 -0
  172. mindroot/coreplugins/chat/static/favicon/favicon_io (1)/favicon.ico +0 -0
  173. mindroot/coreplugins/chat/static/favicon/favicon_io (1)/site.webmanifest +0 -1
  174. mindroot/coreplugins/chat/static/favicon/logo.png +0 -0
  175. mindroot/coreplugins/chat/static/favicon/site.webmanifest +0 -1
  176. mindroot/coreplugins/index/default.json +0 -76
  177. mindroot/coreplugins/user_service/file_trigger_service.py +0 -12
  178. mindroot/coreplugins/user_service/hooks.py +0 -23
  179. /mindroot/coreplugins/{admin/static/favicon/android-chrome-192x192.png → home/static/imgs/backuplogo.png} +0 -0
  180. {mindroot-9.3.0.dist-info → mindroot-9.6.0.dist-info}/WHEEL +0 -0
  181. {mindroot-9.3.0.dist-info → mindroot-9.6.0.dist-info}/entry_points.txt +0 -0
  182. {mindroot-9.3.0.dist-info → mindroot-9.6.0.dist-info}/licenses/LICENSE +0 -0
  183. {mindroot-9.3.0.dist-info → mindroot-9.6.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1031 @@
1
+ import asyncio
2
+ import os
3
+ import json
4
+ import uuid
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from typing import Dict, List, Optional, Any
8
+ from contextlib import AsyncExitStack
9
+ import re
10
+ from urllib.parse import parse_qs, urlparse
11
+ import traceback
12
+
13
+ import httpx
14
+ from pydantic import BaseModel
15
+
16
+ from .server_installer import MCPServerInstaller
17
+ from .dynamic_commands import MCPDynamicCommands
18
+ from .oauth_storage import MCPTokenStorage
19
+
20
+ try:
21
+ from mcp import ClientSession, StdioServerParameters
22
+ from mcp.client.stdio import stdio_client
23
+ from mcp.client.streamable_http import streamablehttp_client
24
+ from mcp.client.sse import sse_client
25
+ from mcp.client.auth import OAuthClientProvider
26
+ from mcp.shared.auth import OAuthClientMetadata
27
+ from pydantic import AnyUrl
28
+ MCP_AVAILABLE = True
29
+ except ImportError:
30
+ # MCP not installed yet
31
+ ClientSession = None
32
+ StdioServerParameters = None
33
+ stdio_client = None
34
+ streamablehttp_client = None
35
+ sse_client = None
36
+ OAuthClientProvider = None
37
+ OAuthClientMetadata = None
38
+ OAuthToken = None
39
+ OAuthClientInformationFull = None
40
+ AnyUrl = None
41
+ MCP_AVAILABLE = False
42
+
43
+ def _substitute_secrets(config_item: Any, secrets: Dict[str, str]) -> Any:
44
+ if not secrets or config_item is None:
45
+ print("DEBUG: No secrets to substitute or config_item is None, not substituting", config_item)
46
+ return config_item
47
+
48
+ if isinstance(config_item, str):
49
+ print("String")
50
+ # Find all placeholders like <SECRET_NAME> or ${SECRET_NAME}
51
+ # This regex captures the name inside the brackets/braces
52
+ placeholder_keys = re.findall(r'<([A-Z0-9_]+)>|\${([A-Z0-9_]+)}', config_item)
53
+ # Flatten list of tuples and remove empty matches
54
+ keys_to_replace = [key for tpl in placeholder_keys for key in tpl if key]
55
+
56
+ temp_item = config_item
57
+ for key in keys_to_replace:
58
+ if key in secrets:
59
+ # Replace both placeholder formats
60
+ temp_item = temp_item.replace(f'<{key}>', secrets[key])
61
+ temp_item = temp_item.replace(f'${{{key}}}', secrets[key])
62
+ return temp_item
63
+
64
+ if isinstance(config_item, list):
65
+ print("List")
66
+ return [_substitute_secrets(item, secrets) for item in config_item]
67
+
68
+ if isinstance(config_item, dict):
69
+ print("DEBUG: Substituting secrets in config_item (dict)")
70
+ return {k: _substitute_secrets(v, secrets) for k, v in config_item.items()}
71
+
72
+ return config_item
73
+
74
+
75
+
76
+ class MCPServer(BaseModel):
77
+ """Model for MCP server configuration"""
78
+ name: str
79
+ description: str
80
+ command: Optional[str] = None
81
+ args: List[str] = []
82
+ env: Dict[str, str] = {}
83
+ transport: str = "stdio" # stdio, sse, websocket, http
84
+ url: Optional[str] = None # for remote servers (legacy single URL)
85
+ # New: explicit provider vs transport URLs
86
+ provider_url: Optional[str] = None # e.g., https://mcp.notion.com
87
+ transport_url: Optional[str] = None # e.g., https://mcp.notion.com/sse or /mcp
88
+ transport_type: Optional[str] = None # "sse" | "streamable_http"
89
+
90
+ # OAuth 2.0 Configuration
91
+ auth_type: str = "none" # none, basic, oauth2
92
+ auth_headers: Dict[str, str] = {} # for basic auth or custom headers
93
+
94
+ # OAuth 2.0 specific fields
95
+ authorization_server_url: Optional[str] = None
96
+ client_id: Optional[str] = None
97
+ client_secret: Optional[str] = None # For confidential clients
98
+ scopes: List[str] = []
99
+ redirect_uri: Optional[str] = None
100
+
101
+ # Token storage
102
+ access_token: Optional[str] = None
103
+ refresh_token: Optional[str] = None
104
+ token_expires_at: Optional[str] = None # ISO format datetime string
105
+
106
+ status: str = "disconnected" # connected, disconnected, error
107
+ secrets: Optional[Dict[str, str]] = None
108
+ capabilities: Dict[str, Any] = {}
109
+
110
+ # Installation config (Enhanced features)
111
+ install_method: str = "manual" # uvx, pip, npm, manual
112
+ install_package: Optional[str] = None
113
+ auto_install: bool = False
114
+ installed: bool = False
115
+
116
+
117
+ class MCPManager:
118
+ """Manages MCP server connections and operations"""
119
+
120
+ def __init__(self):
121
+ self.servers: Dict[str, MCPServer] = {}
122
+ self.sessions: Dict[str, ClientSession] = {}
123
+ self.exit_stacks: Dict[str, AsyncExitStack] = {}
124
+ self.background_tasks: Dict[str, asyncio.Task] = {}
125
+ self.pending_oauth_flows: Dict[str, Dict[str, Any]] = {}
126
+ # Debug/diagnostics: short-lived cache of last discovered capabilities per server
127
+ self.last_capabilities: Dict[str, Dict[str, Any]] = {}
128
+ self.installer = MCPServerInstaller()
129
+ self.dynamic_commands = MCPDynamicCommands()
130
+ self.config_file = Path("/tmp/mcp_servers.json")
131
+
132
+ # Set sessions reference for dynamic commands
133
+ self.dynamic_commands.set_sessions(self.sessions)
134
+
135
+ self.load_config()
136
+
137
+ # ---- Serialization helpers ----
138
+ def _server_to_jsonable(self, server: MCPServer) -> Dict[str, Any]:
139
+ """Convert server model to plain JSON-serializable dict.
140
+ Ensures AnyUrl or other exotic types are stringified.
141
+ """
142
+ # Start with pydantic dict (already basic types), but normalize URLs just in case
143
+ data = server.dict()
144
+ for key in ("url", "provider_url", "transport_url", "authorization_server_url", "redirect_uri"):
145
+ val = data.get(key)
146
+ if val is not None and not isinstance(val, (str, int, float, bool)): # e.g., AnyUrl
147
+ try:
148
+ data[key] = str(val)
149
+ except Exception:
150
+ data[key] = f"{val}"
151
+ return data
152
+
153
+ # ---- URL/Transport helpers ----
154
+ def _infer_urls(self, server: MCPServer) -> tuple[str, str, str]:
155
+ """Infer provider_url, transport_url, and transport_type from server fields.
156
+
157
+ transport_type returns one of: 'sse', 'streamable_http'.
158
+ """
159
+ # Prefer explicit transport_url, otherwise fallback to legacy url
160
+ turl = (server.transport_url or server.url or "").strip()
161
+ if not turl:
162
+ # As last resort, try to build from provider_url + transport
163
+ if server.provider_url and server.transport:
164
+ base = server.provider_url.rstrip('/')
165
+ if server.transport == 'sse':
166
+ turl = f"{base}/sse"
167
+ elif server.transport in ('http', 'streamable_http'):
168
+ turl = f"{base}/mcp"
169
+ # Determine type from suffix if not set
170
+ ttype = server.transport_type
171
+ if not ttype:
172
+ if turl.endswith('/sse'):
173
+ ttype = 'sse'
174
+ elif turl.endswith('/mcp'):
175
+ ttype = 'streamable_http'
176
+ else:
177
+ # fallback based on declared transport
178
+ if server.transport == 'sse':
179
+ ttype = 'sse'
180
+ else:
181
+ ttype = 'streamable_http'
182
+ # Determine provider_url
183
+ provider = server.provider_url
184
+ if not provider:
185
+ # Strip known suffixes
186
+ if turl.endswith('/sse'):
187
+ provider = turl[: -len('/sse')]
188
+ elif turl.endswith('/mcp'):
189
+ provider = turl[: -len('/mcp')]
190
+ else:
191
+ # Use scheme://host[:port]
192
+ try:
193
+ from urllib.parse import urlsplit
194
+ parts = urlsplit(turl)
195
+ if parts.scheme and parts.netloc:
196
+ provider = f"{parts.scheme}://{parts.netloc}"
197
+ else:
198
+ provider = turl.rstrip('/')
199
+ except Exception:
200
+ provider = turl.rstrip('/')
201
+ return provider.rstrip('/'), turl.rstrip('/'), ttype
202
+
203
+ def _update_server_urls(self, name: str, provider_url: str, transport_url: str, transport_type: str) -> None:
204
+ """Persist inferred URL fields back to the server config if missing/outdated."""
205
+ srv = self.servers.get(name)
206
+ if not srv:
207
+ return
208
+ changed = False
209
+ if not srv.provider_url:
210
+ srv.provider_url = provider_url
211
+ changed = True
212
+ if not srv.transport_url:
213
+ srv.transport_url = transport_url
214
+ changed = True
215
+ if not srv.transport_type:
216
+ srv.transport_type = transport_type
217
+ changed = True
218
+ #if changed:
219
+ #self.save_config()
220
+ # Debug log
221
+ try:
222
+ print(f"DEBUG: _update_server_urls: name={name} provider={provider_url} transport={transport_url} type={transport_type}")
223
+ except Exception:
224
+ pass
225
+
226
+ def _build_oauth_provider(self, name: str, server: MCPServer, provider_url: str):
227
+ """Create an OAuthClientProvider bound to this server using persistent storage."""
228
+ base_url = os.getenv('BASE_URL', 'http://localhost:3000')
229
+ callback_url = f"{base_url.rstrip('/')}/mcp_oauth_cb"
230
+ # Storage persists tokens into the server record
231
+ storage = MCPTokenStorage(name, self)
232
+ metadata = OAuthClientMetadata(
233
+ client_name=f"MindRoot - {server.name}",
234
+ # Use plain string to avoid pydantic AnyUrl leaking into persisted state
235
+ redirect_uris=[str(callback_url)],
236
+ grant_types=["authorization_code", "refresh_token"],
237
+ response_types=["code"],
238
+ scope=" ".join(server.scopes) if server.scopes else "user",
239
+ )
240
+ print("DEBUG: -------------------------------------------------------")
241
+ print("DEBUG: OAuth provider server_url:", provider_url)
242
+
243
+ print("DEBUG: OAuth provider metadata:", metadata.dict())
244
+ oauth_provider = OAuthClientProvider(
245
+ server_url=provider_url,
246
+ client_metadata=metadata,
247
+ storage=storage,
248
+ redirect_handler=lambda auth_url: self._handle_oauth_redirect(name, auth_url),
249
+ callback_handler=lambda: self._handle_oauth_callback(name),
250
+ )
251
+
252
+ return oauth_provider
253
+
254
+ def load_config(self):
255
+ """Load server configurations from file"""
256
+ if self.config_file.exists():
257
+ try:
258
+ with open(self.config_file, 'r') as f:
259
+ data = json.load(f)
260
+ for name, config in data.items():
261
+ self.servers[name] = MCPServer(**config)
262
+ except Exception as e:
263
+ print(f"Error loading MCP config: {e}")
264
+
265
+ def save_config(self):
266
+ """Save server configurations to file - LOCAL SERVERS ONLY"""
267
+ try:
268
+ data = {}
269
+ for name, server in self.servers.items():
270
+ # Only save local servers (stdio transport with no URL)
271
+ print("Determining if server is local:", name, server.transport, server.url, server.provider_url, server.transport_url, server.command)
272
+ if self._is_local_server(server):
273
+ print(f"DEBUG: Saving local server {name} to config")
274
+ data[name] = self._server_to_jsonable(server)
275
+ else:
276
+ print(f"DEBUG: Skipping remote server {name} from config")
277
+
278
+ print(f"DEBUG: Saving {len(data)} local servers to config (filtered out {len(self.servers) - len(data)} remote servers)")
279
+ with open(self.config_file, 'w') as f:
280
+ json.dump(data, f, indent=2)
281
+ except Exception as e:
282
+ print(f"Error saving MCP config: {e}")
283
+ raise e
284
+
285
+ def _is_local_server(self, server: MCPServer) -> bool:
286
+ """Determine if a server is local (should be persisted) or remote (session-only)"""
287
+ # Local servers use stdio transport and have no URL
288
+ return (server.transport == "stdio" and
289
+ not server.url and
290
+ not server.provider_url and
291
+ not server.transport_url and
292
+ server.command) # Local servers must have a command
293
+
294
+ def mark_server_as_installed(self, name: str, registry_id: str = None):
295
+ """Mark server as installed (in-memory only for remote servers)"""
296
+ if name in self.servers:
297
+ self.servers[name].installed = True
298
+ if registry_id:
299
+ setattr(self.servers[name], 'registry_id', registry_id)
300
+
301
+ # Only save to config if it's a local server
302
+ if self._is_local_server(self.servers[name]):
303
+ self.save_config()
304
+ async def _persistent_oauth_connection(self, name: str) -> None:
305
+ """Background task to maintain persistent OAuth connection."""
306
+ server = self.servers[name]
307
+ print(f"DEBUG: Starting persistent OAuth connection task for {name}")
308
+
309
+ try:
310
+ # Determine transport details and ensure config is updated
311
+ provider_url, transport_url, transport_type = self._infer_urls(server)
312
+ self._update_server_urls(name, provider_url, transport_url, transport_type)
313
+
314
+ # Create OAuth client provider using persistent storage
315
+ oauth_provider = self._build_oauth_provider(name, server, provider_url)
316
+
317
+ print(f"DEBUG: Persistent task connecting to {transport_url} via {transport_type}")
318
+
319
+ # Keep connection alive indefinitely using appropriate transport
320
+ if transport_type == "sse":
321
+ print(f"DEBUG: About to create SSE client for {transport_url}")
322
+ print(f"DEBUG: OAuth provider: {oauth_provider}")
323
+ async with sse_client(url=transport_url, auth=oauth_provider) as (read, write):
324
+ print(f"DEBUG: Persistent SSE transport created for {name}")
325
+ print(f"DEBUG: SSE read: {read}, write: {write}")
326
+ print(f"DEBUG: About to create ClientSession")
327
+ async with ClientSession(read, write) as session:
328
+ print(f"DEBUG: Persistent session created for {name}")
329
+
330
+ # Initialize the session
331
+ print(f"DEBUG: About to initialize session for {name}")
332
+ await session.initialize()
333
+ print(f"DEBUG: Persistent session initialized for {name}")
334
+
335
+ # Store session globally
336
+ self.sessions[name] = session
337
+ server.status = "connected"
338
+
339
+ # Get server capabilities
340
+ try:
341
+ tools = []
342
+ resources = []
343
+ prompts = []
344
+ print(f"DEBUG: About to list tools for {name}")
345
+ print("DEBUG: listing tools")
346
+ tools = await session.list_tools()
347
+ print("DEBUG: tools listed successfully", tools)
348
+ print("DEBUG: listing resources")
349
+ try:
350
+ resources = await session.list_resources()
351
+ except Exception as e:
352
+ print(f"Error listing resources for {name}: {e}")
353
+ resources = []
354
+ print("DEBUG: resources listed successfully", resources)
355
+ print("DEBUG: listing prompts")
356
+ try:
357
+ prompts = await session.list_prompts()
358
+ except Exception as e:
359
+ print(f"Error listing prompts for {name}: {e}")
360
+
361
+ print(f"DEBUG: Processing capabilities - tools type: {type(tools)}, tools: {tools}")
362
+ server.capabilities = {
363
+ "tools": [tool.dict() for tool in tools.tools]
364
+ #"resources": [res.dict() for res in resources.resources]
365
+ }
366
+ self.servers[name] = server
367
+ self.last_capabilities[name] = server.capabilities
368
+ print(f"DEBUG: Retrieved capabilities for {name}: {len(tools.tools)} tools")
369
+
370
+ # Register dynamic commands
371
+ print(f"DEBUG: Registering tools for {name}...")
372
+ await self.dynamic_commands.register_tools(name, tools.tools)
373
+ print(f"DEBUG: Successfully registered {len(tools.tools)} tools for {name}")
374
+ except Exception as e:
375
+ print(f"Error getting capabilities for {name}: {e}")
376
+ # Don't set status to error if we got this far - keep it connected
377
+ pass
378
+
379
+ #self.save_config()
380
+ print(f"DEBUG: Capabilities saved for {name}, tools={len(server.capabilities.get('tools', []))}")
381
+ print(f"DEBUG: Persistent SSE connection established for {name}")
382
+
383
+ # Keep the task alive until cancelled
384
+ try:
385
+ while True:
386
+ await asyncio.sleep(60) # Heartbeat every minute
387
+ except asyncio.CancelledError:
388
+ print(f"DEBUG: Persistent connection task cancelled for {name}")
389
+ raise
390
+ else:
391
+ async with streamablehttp_client(url=transport_url, auth=oauth_provider) as (read, write, _):
392
+ print(f"DEBUG: Persistent StreamableHTTP transport created for {name}")
393
+ async with ClientSession(read, write) as session:
394
+ print(f"DEBUG: Persistent session created for {name}")
395
+
396
+ # Initialize the session
397
+ print(f"DEBUG: About to initialize session for {name}")
398
+ await session.initialize()
399
+ print(f"DEBUG: Persistent session initialized for {name}")
400
+
401
+ # Store session globally
402
+ self.sessions[name] = session
403
+ server.status = "connected"
404
+
405
+ # Get server capabilities
406
+ try:
407
+ tools = await session.list_tools()
408
+ resources = await session.list_resources()
409
+ prompts = await session.list_prompts()
410
+
411
+ server.capabilities = {
412
+ "tools": [tool.dict() for tool in tools.tools],
413
+ "resources": [res.dict() for res in resources.resources],
414
+ "prompts": [prompt.dict() for prompt in prompts.prompts]
415
+ }
416
+ self.last_capabilities[name] = server.capabilities
417
+ # Save to diagnostics cache
418
+ self.last_capabilities[name] = server.capabilities
419
+ print(f"DEBUG: Retrieved capabilities for {name}: {len(tools.tools)} tools")
420
+
421
+ # Register dynamic commands
422
+ print(f"DEBUG: Registering tools for {name}...")
423
+ await self.dynamic_commands.register_tools(name, tools.tools)
424
+ print(f"DEBUG: Successfully registered {len(tools.tools)} tools for {name}")
425
+ except Exception as e:
426
+ print(f"Error getting capabilities for {name}: {e}")
427
+ # Don't set status to error if we got this far - keep it connected
428
+ pass
429
+
430
+ #self.save_config()
431
+ print(f"DEBUG: Capabilities saved for {name}, tools={len(server.capabilities.get('tools', []))}")
432
+ print(f"DEBUG: Capabilities saved for {name}, tools={len(server.capabilities.get('tools', []))}")
433
+ print(f"DEBUG: Persistent StreamableHTTP connection established for {name}")
434
+
435
+ # Keep the task alive until cancelled
436
+ try:
437
+ while True:
438
+ await asyncio.sleep(60) # Heartbeat every minute
439
+ except asyncio.CancelledError:
440
+ print(f"DEBUG: Persistent connection task cancelled for {name}")
441
+ raise
442
+
443
+ except Exception as e:
444
+ print(f"ERROR: Persistent OAuth connection failed for {name}: {e}")
445
+ import traceback
446
+ traceback.print_exc()
447
+ t = traceback.format_exc()
448
+ server.status = "error"
449
+ self.save_config()
450
+ raise
451
+
452
+ async def connect_oauth_server(self, name: str) -> bool:
453
+ """Connect to an OAuth-protected MCP server."""
454
+ print("Connecting to OAuth server:", name)
455
+ if not MCP_AVAILABLE:
456
+ raise ImportError("MCP SDK not installed. Run: pip install mcp")
457
+ print('1')
458
+ if name not in self.servers:
459
+ print("Server not found:", name, " Known:", list(self.servers.keys()))
460
+ return False
461
+
462
+ server = self.servers[name]
463
+ print('2')
464
+ if server.auth_type != "oauth2":
465
+ print("Server is not configured for OAuth2:", name)
466
+ return await self.connect_server(name) # Fallback to regular connection
467
+
468
+ print(f"DEBUG: Starting OAuth connection for {name}")
469
+
470
+
471
+ # If already connected via background task, return success
472
+ if False and name in self.background_tasks and not self.background_tasks[name].done():
473
+ print(f"DEBUG: OAuth server {name} already connected via background task")
474
+ return True
475
+
476
+ # Clean up any old background task
477
+ if name in self.background_tasks:
478
+ self.background_tasks[name].cancel()
479
+ del self.background_tasks[name]
480
+
481
+ try:
482
+ # Start background task for persistent connection
483
+ task = asyncio.create_task(self._persistent_oauth_connection(name))
484
+ self.background_tasks[name] = task
485
+ await asyncio.sleep(2)
486
+
487
+ # Check if connection was successful or OAuth flow started
488
+ if name in self.sessions and server.status == "connected":
489
+ print(f"DEBUG: Background OAuth connection successful for {name}")
490
+ return True
491
+ elif False and server.status == "error":
492
+ print(f"DEBUG: Background OAuth connection failed for {name}")
493
+ # Check if the background task had an exception
494
+ if not task.done():
495
+ task.cancel()
496
+ return False
497
+ elif name in self.pending_oauth_flows:
498
+ print(f"DEBUG: OAuth flow started for {name}, frontend should handle popup")
499
+ return False # This will trigger the OAuth flow check in the calling code
500
+ else:
501
+ print(f"DEBUG: (X) Background OAuth connection failed for {name}, {server.status} {self.sessions}")
502
+ return False
503
+
504
+ except Exception as e:
505
+ server.status = "error"
506
+ t = traceback.format_exc()
507
+ self.save_config()
508
+ print(f"Error starting OAuth connection for {name}: {e}\n{t}")
509
+ return False
510
+
511
+ async def _handle_oauth_redirect(self, server_name: str, auth_url: str) -> None:
512
+ """Handle OAuth redirect - store auth URL for frontend to handle."""
513
+ flow_id = str(uuid.uuid4())
514
+ print(f"DEBUG: Creating OAuth flow for {server_name}")
515
+ print(f"DEBUG: Auth URL: {auth_url}")
516
+ self.pending_oauth_flows[server_name] = {
517
+ "flow_id": flow_id,
518
+ "auth_url": auth_url,
519
+ "status": "awaiting_authorization",
520
+ "code": None,
521
+ "state": None
522
+ }
523
+ print(f"DEBUG: OAuth flow created with ID: {flow_id}")
524
+ print(f"OAuth flow started for {server_name}: {auth_url}")
525
+
526
+ async def _handle_oauth_callback(self, server_name: str) -> tuple[str, Optional[str]]:
527
+ """Handle OAuth callback - get code from pending flow."""
528
+ if server_name not in self.pending_oauth_flows:
529
+ print(f"DEBUG: No pending OAuth flow found for {server_name}")
530
+ print(f"DEBUG: Available flows: {list(self.pending_oauth_flows.keys())}")
531
+ raise ValueError(f"No pending OAuth flow for server {server_name}")
532
+
533
+ flow = self.pending_oauth_flows[server_name]
534
+
535
+ # Wait for callback to be processed by frontend
536
+ max_wait = 300 # 5 minutes
537
+ wait_time = 0
538
+ print(f"DEBUG: Waiting for OAuth callback for {server_name}, current status: {flow['status']}")
539
+
540
+ while flow["status"] == "awaiting_authorization" and wait_time < max_wait:
541
+ if wait_time % 10 == 0: # Log every 10 seconds
542
+ print(f"DEBUG: Still waiting for OAuth callback... {wait_time}s elapsed")
543
+ await asyncio.sleep(1)
544
+ wait_time += 1
545
+
546
+ print(f"DEBUG: OAuth wait completed. Status: {flow['status']}, wait_time: {wait_time}")
547
+ code = flow["code"]
548
+ state = flow["state"]
549
+
550
+ print(f"DEBUG: OAuth flow completed for {server_name}, code: {code}, state: {state}")
551
+
552
+ if flow["status"] != "callback_received":
553
+ raise ValueError("OAuth authorization timed out or failed")
554
+
555
+ # Clean up flow
556
+ del self.pending_oauth_flows[server_name]
557
+
558
+ return code, state
559
+
560
+ def start_oauth_flow(self, server_name: str) -> Optional[str]:
561
+ """Start OAuth flow and return authorization URL."""
562
+ if server_name in self.pending_oauth_flows:
563
+ print(f"DEBUG: Found existing OAuth flow for {server_name}")
564
+ flow = self.pending_oauth_flows[server_name]
565
+ if flow["status"] == "awaiting_authorization":
566
+ print(f"DEBUG: Returning existing auth URL: {flow['auth_url']}")
567
+ return flow["auth_url"]
568
+ return None
569
+
570
+ def complete_oauth_flow(self, server_name: str, code: str, state: Optional[str] = None) -> bool:
571
+ """Complete OAuth flow with authorization code."""
572
+ if server_name not in self.pending_oauth_flows:
573
+ print(f"DEBUG: complete_oauth_flow: server_name '{server_name}' not in pending flows. Available: {list(self.pending_oauth_flows.keys())}")
574
+ return False
575
+
576
+ flow = self.pending_oauth_flows[server_name]
577
+ flow["code"] = code
578
+ flow["state"] = state
579
+ flow["status"] = "callback_received"
580
+ print(f"DEBUG: complete_oauth_flow: marked callback_received for {server_name}, state={state} code_present={bool(code)}")
581
+
582
+ return True
583
+
584
+ def get_oauth_status(self, server_name: str) -> Dict[str, Any]:
585
+ """Get OAuth flow status for a server."""
586
+ if server_name not in self.servers:
587
+ print(f"DEBUG: get_oauth_status: Server not found: '{server_name}'. Known servers: {list(self.servers.keys())}")
588
+ return {"error": "Server not found"}
589
+
590
+ server = self.servers[server_name]
591
+
592
+ status = {
593
+ "server_name": server_name,
594
+ "auth_type": server.auth_type,
595
+ "status": server.status,
596
+ "has_tokens": bool(server.access_token),
597
+ "token_expires_at": server.token_expires_at,
598
+ "scopes": server.scopes
599
+ }
600
+
601
+ if server_name in self.pending_oauth_flows:
602
+ flow = self.pending_oauth_flows[server_name]
603
+ status["oauth_flow"] = {
604
+ "flow_id": flow["flow_id"],
605
+ "status": flow["status"],
606
+ "auth_url": flow["auth_url"] if flow["status"] == "awaiting_authorization" else None
607
+ }
608
+ # Add diagnostics: last capabilities snapshot size
609
+ try:
610
+ if server_name in self.last_capabilities:
611
+ status["last_tools_count"] = len(self.last_capabilities[server_name].get("tools", []))
612
+ except Exception:
613
+ pass
614
+
615
+ return status
616
+
617
+ async def connect_remote_server(self, name: str) -> bool:
618
+ """Connect to a remote MCP server (HTTP/SSE)."""
619
+ print("remote connect")
620
+ if not MCP_AVAILABLE:
621
+ print("import error")
622
+ raise ImportError("MCP SDK not installed. Run: pip install mcp")
623
+
624
+ if name not in self.servers:
625
+ print("Server not found:", name, " Known:", list(self.servers.keys()))
626
+ return False
627
+
628
+ server = self.servers[name]
629
+
630
+ if server.auth_type == "oauth2":
631
+ print("connect oauth server:", name)
632
+ return await self.connect_oauth_server(name)
633
+
634
+ # Handle basic auth or no auth remote servers
635
+ try:
636
+ # Create exit stack for cleanup
637
+ exit_stack = AsyncExitStack()
638
+ self.exit_stacks[name] = exit_stack
639
+
640
+ # Determine transport details
641
+ provider_url, transport_url, transport_type = self._infer_urls(server)
642
+ self._update_server_urls(name, provider_url, transport_url, transport_type)
643
+
644
+ # Use appropriate transport based on type
645
+ if transport_type == "sse":
646
+ print(f"Connecting via SSE to: {transport_url}")
647
+ transport = await exit_stack.enter_async_context(
648
+ sse_client(transport_url)
649
+ )
650
+ # SSE returns (read, write) - no session_id
651
+ session = await exit_stack.enter_async_context(
652
+ ClientSession(transport[0], transport[1])
653
+ )
654
+ else: # streamable_http
655
+ print(f"Connecting via StreamableHTTP to: {transport_url}")
656
+ transport = await exit_stack.enter_async_context(
657
+ streamablehttp_client(transport_url)
658
+ )
659
+ # StreamableHTTP returns (read, write, get_session_id)
660
+ session = await exit_stack.enter_async_context(
661
+ ClientSession(transport[0], transport[1])
662
+ )
663
+ print("Session created:", session)
664
+ # Initialize the session
665
+ await session.initialize()
666
+
667
+ # Store session
668
+ self.sessions[name] = session
669
+
670
+ # Update server status and capabilities
671
+ server.status = "connected"
672
+
673
+ # Get server capabilities
674
+ try:
675
+ tools = await session.list_tools()
676
+ resources = await session.list_resources()
677
+ prompts = await session.list_prompts()
678
+
679
+ server.capabilities = {
680
+ "tools": [tool.dict() for tool in tools.tools],
681
+ "resources": [res.dict() for res in resources.resources],
682
+ "prompts": [prompt.dict() for prompt in prompts.prompts]
683
+ }
684
+ self.last_capabilities[name] = server.capabilities
685
+ except Exception as e:
686
+ print(f"Error getting capabilities for {name}: {e}")
687
+
688
+ self.save_config()
689
+ print(f"DEBUG: connect_remote_server: saved capabilities for {name}, tools={len(server.capabilities.get('tools', []))}")
690
+ return True
691
+
692
+ except Exception as e:
693
+ server.status = "error"
694
+ self.save_config()
695
+ print(f"Error connecting to remote server {name}: {e}")
696
+ return False
697
+
698
+ async def sanity_test(self) -> str:
699
+ print("Basic MCP Manager sanity test")
700
+ return "OK"
701
+
702
+ async def install_server(self, server_name: str) -> bool:
703
+ """Install an MCP server"""
704
+ if server_name not in self.servers:
705
+ return False
706
+
707
+ server = self.servers[server_name]
708
+
709
+ if server.install_method == "manual":
710
+ return True
711
+
712
+ if server.install_method == "uvx":
713
+ success = await self.installer.install_with_uvx(
714
+ server.install_package or server_name
715
+ )
716
+ elif server.install_method == "pip":
717
+ success = await self.installer.install_with_pip(
718
+ server.install_package or server_name
719
+ )
720
+ elif server.install_method == "npm":
721
+ success = await self.installer.install_with_npm(
722
+ server.install_package or server_name
723
+ )
724
+ elif server.install_method == "npx":
725
+ success = await self.installer.install_with_npx(
726
+ server.install_package or server_name
727
+ )
728
+ else:
729
+ return False
730
+
731
+ if success:
732
+ server.installed = True
733
+ # Only save config for local servers
734
+ if self._is_local_server(server):
735
+ self.save_config()
736
+
737
+ return success
738
+
739
+ async def connect_server(self, name: str, secrets: Optional[Dict[str, str]] = None) -> bool:
740
+ """Connect to an MCP server"""
741
+ print("Connecting to MCP server:", name)
742
+ if name not in self.servers:
743
+ print("Server not found:", name, " Known:", list(self.servers.keys()))
744
+ return False
745
+ if ClientSession is None:
746
+ raise ImportError("MCP SDK not installed. Run: pip install mcp")
747
+
748
+ server = self.servers[name]
749
+ print("DEBUG: secrets passed in connect_server:", secrets)
750
+ # Auto-install if needed
751
+ if server.auto_install and not server.installed:
752
+ print(f"Auto-installing {name}...")
753
+ if not await self.install_server(name):
754
+ print(f"Failed to auto-install {name}")
755
+ return False
756
+
757
+ # Route to appropriate connection method based on transport
758
+ if server.transport in ["http", "sse", "websocket"] or server.url:
759
+ return await self.connect_remote_server(name)
760
+
761
+ try:
762
+ if server.transport == "stdio":
763
+ import copy
764
+
765
+ # Combine stored secrets with any provided for this session
766
+ all_secrets = (server.secrets or {}).copy()
767
+ if secrets:
768
+ for k, v in secrets.items():
769
+ if v is not None and v != "":
770
+ all_secrets[k] = v
771
+
772
+ final_command = _substitute_secrets(server.command, all_secrets)
773
+ args_ = _substitute_secrets(copy.deepcopy(server.args), all_secrets)
774
+ env_ = _substitute_secrets(copy.deepcopy(server.env), all_secrets)
775
+ for key, value in all_secrets.items():
776
+ if key in env_:
777
+ if value is not None and value is not "":
778
+ env_[key] = value
779
+ final_env = {k: v for k, v in env_.items() if v is not None}
780
+ final_args = [str(arg) for arg in args_ if arg is not None]
781
+ print("DEBUG: server secrets:", server.secrets)
782
+ print(f"DEBUG: connect_server: final_command={final_command}, final_args={final_args}, final_env={final_env}")
783
+ # Create server parameters
784
+ server_params = StdioServerParameters(
785
+ command=final_command,
786
+ args=final_args,
787
+ env=final_env
788
+ )
789
+
790
+ # Create exit stack for cleanup
791
+ exit_stack = AsyncExitStack()
792
+ self.exit_stacks[name] = exit_stack
793
+
794
+ # Connect via stdio
795
+ stdio_transport = await exit_stack.enter_async_context(
796
+ stdio_client(server_params)
797
+ )
798
+
799
+ # Create session
800
+ session = await exit_stack.enter_async_context(
801
+ ClientSession(stdio_transport[0], stdio_transport[1])
802
+ )
803
+
804
+ # Initialize the session
805
+ await session.initialize()
806
+
807
+ # Store session
808
+ self.sessions[name] = session
809
+
810
+ # Update server status and capabilities
811
+ server.status = "connected"
812
+
813
+ # Get server capabilities
814
+ try:
815
+ tools = await session.list_tools()
816
+ print(f"DEBUG: Retrieved {len(tools.tools)} tools from {name}")
817
+ for tool in tools.tools:
818
+ print(f" Tool: {tool.name} - {tool.description}")
819
+
820
+ #resources = await session.list_resources()
821
+ #prompts = await session.list_prompts()
822
+
823
+ # Safely serialize tools, resources, and prompts
824
+ try:
825
+ tools_data = []
826
+ for tool in tools.tools:
827
+ try:
828
+ tools_data.append(tool.dict())
829
+ except Exception as e:
830
+ print(f" Warning: Failed to serialize tool {tool.name}: {e}")
831
+ # Fallback to basic info
832
+ tools_data.append({
833
+ "name": tool.name,
834
+ "description": getattr(tool, 'description', ''),
835
+ "inputSchema": getattr(tool, 'inputSchema', {})
836
+ })
837
+
838
+ server.capabilities = {
839
+ "tools": tools_data #,
840
+ #"resources": [res.dict() for res in resources.resources],
841
+ #"prompts": [prompt.dict() for prompt in prompts.prompts]
842
+ }
843
+ self.servers[name] = server
844
+ print(f"DEBUG: Saved {len(tools_data)} tools to server capabilities")
845
+ except Exception as e:
846
+ print(f"DEBUG: Error serializing capabilities: {e}")
847
+ # Set basic capabilities even if serialization fails
848
+ server.capabilities = {
849
+ "tools": [{"name": t.name, "description": getattr(t, 'description', '')} for t in tools.tools],
850
+ "resources": [],
851
+ "prompts": []
852
+ }
853
+
854
+ # Register dynamic commands
855
+ print(f"DEBUG: Registering tools for {name}...")
856
+ await self.dynamic_commands.register_tools(name, tools.tools)
857
+ print(f"DEBUG: Successfully registered {len(tools.tools)} tools for {name}")
858
+
859
+ except Exception as e:
860
+ print(f"Error getting capabilities for {name}: {e}")
861
+
862
+ # Only save config for local servers
863
+ if self._is_local_server(server):
864
+ self.save_config()
865
+
866
+ return True
867
+
868
+ except Exception as e:
869
+ print("DEBUG: Error connecting to MCP server:", name, e)
870
+ import traceback
871
+ traceback.print_exc()
872
+ server.status = "error"
873
+ self.save_config()
874
+ print(f"Error connecting to {name}: {e}")
875
+ return False
876
+
877
+ async def test_local_server_capabilities(self, name: str, command: str, args: List[str], env: Dict[str, str], secrets: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
878
+ """Test connection to a local MCP server and return its capabilities.
879
+
880
+ This method creates a temporary server, connects to it, extracts capabilities,
881
+ and cleans up. It follows the same pattern as the working catalog system.
882
+
883
+ Args:
884
+ name: Display name for the server (for error messages)
885
+ command: Command to run the MCP server
886
+ args: Arguments for the command
887
+ env: Environment variables
888
+ secrets: A dictionary of secrets to substitute into placeholders.
889
+
890
+ Returns:
891
+ Dict containing success status, message, and capabilities (tools, resources, prompts)
892
+
893
+ Raises:
894
+ Exception: If MCP SDK is not available or connection fails
895
+ """
896
+ if not MCP_AVAILABLE:
897
+ raise ImportError("MCP SDK not installed. Run: pip install mcp")
898
+
899
+ if not command:
900
+ raise ValueError("Command is required for local servers")
901
+
902
+ # Generate unique temporary server name
903
+ temp_server_name = f"temp_local_test_{uuid.uuid4().hex[:8]}"
904
+
905
+ try:
906
+ # Create temporary server configuration (following catalog pattern)
907
+ temp_server = MCPServer(
908
+ name=temp_server_name,
909
+ description=f"Temporary local server for testing {name}",
910
+ command=command,
911
+ args=args,
912
+ env=env,
913
+ transport="stdio", # Explicitly set stdio transport
914
+ # Explicitly do NOT set url field for local servers
915
+ )
916
+
917
+ print(f"DEBUG: test_local_server_capabilities: Created temp server {temp_server_name}")
918
+
919
+ # Add temporary server to MCP manager
920
+ self.add_server(temp_server_name, temp_server)
921
+
922
+ # Connect to server (this will use stdio connection)
923
+ success = await self.connect_server(temp_server_name, secrets=secrets)
924
+ if not success:
925
+ raise Exception("Failed to connect to local server")
926
+
927
+ print(f"DEBUG: test_local_server_capabilities: Connected to {temp_server_name}")
928
+
929
+ # Get server capabilities
930
+ server = self.servers[temp_server_name]
931
+ tools = server.capabilities.get("tools", [])
932
+ resources = server.capabilities.get("resources", [])
933
+ prompts = server.capabilities.get("prompts", [])
934
+
935
+ print(f"DEBUG: test_local_server_capabilities: Found {len(tools)} tools, {len(resources)} resources, {len(prompts)} prompts")
936
+
937
+ return {
938
+ "success": True,
939
+ "message": f"Successfully connected to local server. Found {len(tools)} tools, {len(resources)} resources, {len(prompts)} prompts.",
940
+ "tools": tools,
941
+ "resources": resources,
942
+ "prompts": prompts
943
+ }
944
+
945
+ finally:
946
+ # Clean up temporary server
947
+ try:
948
+ if temp_server_name in self.sessions:
949
+ await self.disconnect_server(temp_server_name)
950
+ if temp_server_name in self.servers:
951
+ self.remove_server(temp_server_name)
952
+ print(f"DEBUG: test_local_server_capabilities: Cleaned up {temp_server_name}")
953
+ except Exception as cleanup_error:
954
+ print(f"Error cleaning up temporary server {temp_server_name}: {cleanup_error}")
955
+
956
+ async def disconnect_server(self, name: str) -> bool:
957
+ """Disconnect from an MCP server"""
958
+ print("Attempting to disconnect from MCP server:", name)
959
+ if name in self.sessions:
960
+ print(f"DEBUG: Disconnecting from server {name} with session {self.sessions[name]}")
961
+ try:
962
+ # Unregister dynamic commands
963
+ await self.dynamic_commands.unregister_server_tools(name)
964
+
965
+ # Cancel background task if it exists
966
+ if name in self.background_tasks:
967
+ print(f"DEBUG: Cancelling background task for {name}")
968
+ self.background_tasks[name].cancel()
969
+ try:
970
+ await self.background_tasks[name]
971
+ except asyncio.CancelledError:
972
+ pass
973
+ del self.background_tasks[name]
974
+
975
+ # Clean up exit stack (this will close the session)
976
+ if name in self.exit_stacks:
977
+ await self.exit_stacks[name].aclose()
978
+ del self.exit_stacks[name]
979
+
980
+ del self.sessions[name]
981
+
982
+ if name in self.servers:
983
+ self.servers[name].status = "disconnected"
984
+ self.save_config()
985
+
986
+ return True
987
+ except Exception as e:
988
+ print(f"Error disconnecting from {name}: {e}")
989
+ return False
990
+ else:
991
+ print(f"No active session found for {name}. Trying to disconnect with {name} as server name.")
992
+ if name in self.servers:
993
+ await self.dynamic_commands.unregister_server_tools(name)
994
+
995
+ self.servers[name].status = "disconnected"
996
+ self.save_config()
997
+ return True
998
+
999
+ async def call_tool(self, server_name: str, tool_name: str, arguments: Dict[str, Any]) -> Any:
1000
+ """Call a tool on an MCP server"""
1001
+ if server_name not in self.sessions:
1002
+ raise ValueError(f"Server {server_name} not connected")
1003
+
1004
+ session = self.sessions[server_name]
1005
+ result = await session.call_tool(tool_name, arguments)
1006
+ return result
1007
+
1008
+ async def read_resource(self, server_name: str, uri: str) -> Any:
1009
+ """Read a resource from an MCP server"""
1010
+ if server_name not in self.sessions:
1011
+ raise ValueError(f"Server {server_name} not connected")
1012
+
1013
+ session = self.sessions[server_name]
1014
+ result = await session.read_resource(uri)
1015
+ return result
1016
+
1017
+ def add_server(self, name: str, server: MCPServer):
1018
+ """Add a new server configuration"""
1019
+ self.servers[name] = server
1020
+ # Only save config for local servers
1021
+ if self._is_local_server(server):
1022
+ self.save_config()
1023
+ def remove_server(self, name: str):
1024
+ """Remove a server configuration"""
1025
+ if name in self.servers:
1026
+ # Disconnect first if connected
1027
+ if name in self.sessions:
1028
+ asyncio.create_task(self.disconnect_server(name))
1029
+
1030
+ del self.servers[name]
1031
+ self.save_config()