superqode 0.1.5__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 (288) hide show
  1. superqode/__init__.py +33 -0
  2. superqode/acp/__init__.py +23 -0
  3. superqode/acp/client.py +913 -0
  4. superqode/acp/permission_screen.py +457 -0
  5. superqode/acp/types.py +480 -0
  6. superqode/acp_discovery.py +856 -0
  7. superqode/agent/__init__.py +22 -0
  8. superqode/agent/edit_strategies.py +334 -0
  9. superqode/agent/loop.py +892 -0
  10. superqode/agent/qe_report_templates.py +39 -0
  11. superqode/agent/system_prompts.py +353 -0
  12. superqode/agent_output.py +721 -0
  13. superqode/agent_stream.py +953 -0
  14. superqode/agents/__init__.py +59 -0
  15. superqode/agents/acp_registry.py +305 -0
  16. superqode/agents/client.py +249 -0
  17. superqode/agents/data/augmentcode.com.toml +51 -0
  18. superqode/agents/data/cagent.dev.toml +51 -0
  19. superqode/agents/data/claude.com.toml +60 -0
  20. superqode/agents/data/codeassistant.dev.toml +51 -0
  21. superqode/agents/data/codex.openai.com.toml +57 -0
  22. superqode/agents/data/fastagent.ai.toml +66 -0
  23. superqode/agents/data/geminicli.com.toml +77 -0
  24. superqode/agents/data/goose.block.xyz.toml +54 -0
  25. superqode/agents/data/junie.jetbrains.com.toml +56 -0
  26. superqode/agents/data/kimi.moonshot.cn.toml +57 -0
  27. superqode/agents/data/llmlingagent.dev.toml +51 -0
  28. superqode/agents/data/molt.bot.toml +49 -0
  29. superqode/agents/data/opencode.ai.toml +60 -0
  30. superqode/agents/data/stakpak.dev.toml +51 -0
  31. superqode/agents/data/vtcode.dev.toml +51 -0
  32. superqode/agents/discovery.py +266 -0
  33. superqode/agents/messaging.py +160 -0
  34. superqode/agents/persona.py +166 -0
  35. superqode/agents/registry.py +421 -0
  36. superqode/agents/schema.py +72 -0
  37. superqode/agents/unified.py +367 -0
  38. superqode/app/__init__.py +111 -0
  39. superqode/app/constants.py +314 -0
  40. superqode/app/css.py +366 -0
  41. superqode/app/models.py +118 -0
  42. superqode/app/suggester.py +125 -0
  43. superqode/app/widgets.py +1591 -0
  44. superqode/app_enhanced.py +399 -0
  45. superqode/app_main.py +17187 -0
  46. superqode/approval.py +312 -0
  47. superqode/atomic.py +296 -0
  48. superqode/commands/__init__.py +1 -0
  49. superqode/commands/acp.py +965 -0
  50. superqode/commands/agents.py +180 -0
  51. superqode/commands/auth.py +278 -0
  52. superqode/commands/config.py +374 -0
  53. superqode/commands/init.py +826 -0
  54. superqode/commands/providers.py +819 -0
  55. superqode/commands/qe.py +1145 -0
  56. superqode/commands/roles.py +380 -0
  57. superqode/commands/serve.py +172 -0
  58. superqode/commands/suggestions.py +127 -0
  59. superqode/commands/superqe.py +460 -0
  60. superqode/config/__init__.py +51 -0
  61. superqode/config/loader.py +812 -0
  62. superqode/config/schema.py +498 -0
  63. superqode/core/__init__.py +111 -0
  64. superqode/core/roles.py +281 -0
  65. superqode/danger.py +386 -0
  66. superqode/data/superqode-template.yaml +1522 -0
  67. superqode/design_system.py +1080 -0
  68. superqode/dialogs/__init__.py +6 -0
  69. superqode/dialogs/base.py +39 -0
  70. superqode/dialogs/model.py +130 -0
  71. superqode/dialogs/provider.py +870 -0
  72. superqode/diff_view.py +919 -0
  73. superqode/enterprise.py +21 -0
  74. superqode/evaluation/__init__.py +25 -0
  75. superqode/evaluation/adapters.py +93 -0
  76. superqode/evaluation/behaviors.py +89 -0
  77. superqode/evaluation/engine.py +209 -0
  78. superqode/evaluation/scenarios.py +96 -0
  79. superqode/execution/__init__.py +36 -0
  80. superqode/execution/linter.py +538 -0
  81. superqode/execution/modes.py +347 -0
  82. superqode/execution/resolver.py +283 -0
  83. superqode/execution/runner.py +642 -0
  84. superqode/file_explorer.py +811 -0
  85. superqode/file_viewer.py +471 -0
  86. superqode/flash.py +183 -0
  87. superqode/guidance/__init__.py +58 -0
  88. superqode/guidance/config.py +203 -0
  89. superqode/guidance/prompts.py +71 -0
  90. superqode/harness/__init__.py +54 -0
  91. superqode/harness/accelerator.py +291 -0
  92. superqode/harness/config.py +319 -0
  93. superqode/harness/validator.py +147 -0
  94. superqode/history.py +279 -0
  95. superqode/integrations/superopt_runner.py +124 -0
  96. superqode/logging/__init__.py +49 -0
  97. superqode/logging/adapters.py +219 -0
  98. superqode/logging/formatter.py +923 -0
  99. superqode/logging/integration.py +341 -0
  100. superqode/logging/sinks.py +170 -0
  101. superqode/logging/unified_log.py +417 -0
  102. superqode/lsp/__init__.py +26 -0
  103. superqode/lsp/client.py +544 -0
  104. superqode/main.py +1069 -0
  105. superqode/mcp/__init__.py +89 -0
  106. superqode/mcp/auth_storage.py +380 -0
  107. superqode/mcp/client.py +1236 -0
  108. superqode/mcp/config.py +319 -0
  109. superqode/mcp/integration.py +337 -0
  110. superqode/mcp/oauth.py +436 -0
  111. superqode/mcp/oauth_callback.py +385 -0
  112. superqode/mcp/types.py +290 -0
  113. superqode/memory/__init__.py +31 -0
  114. superqode/memory/feedback.py +342 -0
  115. superqode/memory/store.py +522 -0
  116. superqode/notifications.py +369 -0
  117. superqode/optimization/__init__.py +5 -0
  118. superqode/optimization/config.py +33 -0
  119. superqode/permissions/__init__.py +25 -0
  120. superqode/permissions/rules.py +488 -0
  121. superqode/plan.py +323 -0
  122. superqode/providers/__init__.py +33 -0
  123. superqode/providers/gateway/__init__.py +165 -0
  124. superqode/providers/gateway/base.py +228 -0
  125. superqode/providers/gateway/litellm_gateway.py +1170 -0
  126. superqode/providers/gateway/openresponses_gateway.py +436 -0
  127. superqode/providers/health.py +297 -0
  128. superqode/providers/huggingface/__init__.py +74 -0
  129. superqode/providers/huggingface/downloader.py +472 -0
  130. superqode/providers/huggingface/endpoints.py +442 -0
  131. superqode/providers/huggingface/hub.py +531 -0
  132. superqode/providers/huggingface/inference.py +394 -0
  133. superqode/providers/huggingface/transformers_runner.py +516 -0
  134. superqode/providers/local/__init__.py +100 -0
  135. superqode/providers/local/base.py +438 -0
  136. superqode/providers/local/discovery.py +418 -0
  137. superqode/providers/local/lmstudio.py +256 -0
  138. superqode/providers/local/mlx.py +457 -0
  139. superqode/providers/local/ollama.py +486 -0
  140. superqode/providers/local/sglang.py +268 -0
  141. superqode/providers/local/tgi.py +260 -0
  142. superqode/providers/local/tool_support.py +477 -0
  143. superqode/providers/local/vllm.py +258 -0
  144. superqode/providers/manager.py +1338 -0
  145. superqode/providers/models.py +1016 -0
  146. superqode/providers/models_dev.py +578 -0
  147. superqode/providers/openresponses/__init__.py +87 -0
  148. superqode/providers/openresponses/converters/__init__.py +17 -0
  149. superqode/providers/openresponses/converters/messages.py +343 -0
  150. superqode/providers/openresponses/converters/tools.py +268 -0
  151. superqode/providers/openresponses/schema/__init__.py +56 -0
  152. superqode/providers/openresponses/schema/models.py +585 -0
  153. superqode/providers/openresponses/streaming/__init__.py +5 -0
  154. superqode/providers/openresponses/streaming/parser.py +338 -0
  155. superqode/providers/openresponses/tools/__init__.py +21 -0
  156. superqode/providers/openresponses/tools/apply_patch.py +352 -0
  157. superqode/providers/openresponses/tools/code_interpreter.py +290 -0
  158. superqode/providers/openresponses/tools/file_search.py +333 -0
  159. superqode/providers/openresponses/tools/mcp_adapter.py +252 -0
  160. superqode/providers/registry.py +716 -0
  161. superqode/providers/usage.py +332 -0
  162. superqode/pure_mode.py +384 -0
  163. superqode/qr/__init__.py +23 -0
  164. superqode/qr/dashboard.py +781 -0
  165. superqode/qr/generator.py +1018 -0
  166. superqode/qr/templates.py +135 -0
  167. superqode/safety/__init__.py +41 -0
  168. superqode/safety/sandbox.py +413 -0
  169. superqode/safety/warnings.py +256 -0
  170. superqode/server/__init__.py +33 -0
  171. superqode/server/lsp_server.py +775 -0
  172. superqode/server/web.py +250 -0
  173. superqode/session/__init__.py +25 -0
  174. superqode/session/persistence.py +580 -0
  175. superqode/session/sharing.py +477 -0
  176. superqode/session.py +475 -0
  177. superqode/sidebar.py +2991 -0
  178. superqode/stream_view.py +648 -0
  179. superqode/styles/__init__.py +3 -0
  180. superqode/superqe/__init__.py +184 -0
  181. superqode/superqe/acp_runner.py +1064 -0
  182. superqode/superqe/constitution/__init__.py +62 -0
  183. superqode/superqe/constitution/evaluator.py +308 -0
  184. superqode/superqe/constitution/loader.py +432 -0
  185. superqode/superqe/constitution/schema.py +250 -0
  186. superqode/superqe/events.py +591 -0
  187. superqode/superqe/frameworks/__init__.py +65 -0
  188. superqode/superqe/frameworks/base.py +234 -0
  189. superqode/superqe/frameworks/e2e.py +263 -0
  190. superqode/superqe/frameworks/executor.py +237 -0
  191. superqode/superqe/frameworks/javascript.py +409 -0
  192. superqode/superqe/frameworks/python.py +373 -0
  193. superqode/superqe/frameworks/registry.py +92 -0
  194. superqode/superqe/mcp_tools/__init__.py +47 -0
  195. superqode/superqe/mcp_tools/core_tools.py +418 -0
  196. superqode/superqe/mcp_tools/registry.py +230 -0
  197. superqode/superqe/mcp_tools/testing_tools.py +167 -0
  198. superqode/superqe/noise.py +89 -0
  199. superqode/superqe/orchestrator.py +778 -0
  200. superqode/superqe/roles.py +609 -0
  201. superqode/superqe/session.py +713 -0
  202. superqode/superqe/skills/__init__.py +57 -0
  203. superqode/superqe/skills/base.py +106 -0
  204. superqode/superqe/skills/core_skills.py +899 -0
  205. superqode/superqe/skills/registry.py +90 -0
  206. superqode/superqe/verifier.py +101 -0
  207. superqode/superqe_cli.py +76 -0
  208. superqode/tool_call.py +358 -0
  209. superqode/tools/__init__.py +93 -0
  210. superqode/tools/agent_tools.py +496 -0
  211. superqode/tools/base.py +324 -0
  212. superqode/tools/batch_tool.py +133 -0
  213. superqode/tools/diagnostics.py +311 -0
  214. superqode/tools/edit_tools.py +653 -0
  215. superqode/tools/enhanced_base.py +515 -0
  216. superqode/tools/file_tools.py +269 -0
  217. superqode/tools/file_tracking.py +45 -0
  218. superqode/tools/lsp_tools.py +610 -0
  219. superqode/tools/network_tools.py +350 -0
  220. superqode/tools/permissions.py +400 -0
  221. superqode/tools/question_tool.py +324 -0
  222. superqode/tools/search_tools.py +598 -0
  223. superqode/tools/shell_tools.py +259 -0
  224. superqode/tools/todo_tools.py +121 -0
  225. superqode/tools/validation.py +80 -0
  226. superqode/tools/web_tools.py +639 -0
  227. superqode/tui.py +1152 -0
  228. superqode/tui_integration.py +875 -0
  229. superqode/tui_widgets/__init__.py +27 -0
  230. superqode/tui_widgets/widgets/__init__.py +18 -0
  231. superqode/tui_widgets/widgets/progress.py +185 -0
  232. superqode/tui_widgets/widgets/tool_display.py +188 -0
  233. superqode/undo_manager.py +574 -0
  234. superqode/utils/__init__.py +5 -0
  235. superqode/utils/error_handling.py +323 -0
  236. superqode/utils/fuzzy.py +257 -0
  237. superqode/widgets/__init__.py +477 -0
  238. superqode/widgets/agent_collab.py +390 -0
  239. superqode/widgets/agent_store.py +936 -0
  240. superqode/widgets/agent_switcher.py +395 -0
  241. superqode/widgets/animation_manager.py +284 -0
  242. superqode/widgets/code_context.py +356 -0
  243. superqode/widgets/command_palette.py +412 -0
  244. superqode/widgets/connection_status.py +537 -0
  245. superqode/widgets/conversation_history.py +470 -0
  246. superqode/widgets/diff_indicator.py +155 -0
  247. superqode/widgets/enhanced_status_bar.py +385 -0
  248. superqode/widgets/enhanced_toast.py +476 -0
  249. superqode/widgets/file_browser.py +809 -0
  250. superqode/widgets/file_reference.py +585 -0
  251. superqode/widgets/issue_timeline.py +340 -0
  252. superqode/widgets/leader_key.py +264 -0
  253. superqode/widgets/mode_switcher.py +445 -0
  254. superqode/widgets/model_picker.py +234 -0
  255. superqode/widgets/permission_preview.py +1205 -0
  256. superqode/widgets/prompt.py +358 -0
  257. superqode/widgets/provider_connect.py +725 -0
  258. superqode/widgets/pty_shell.py +587 -0
  259. superqode/widgets/qe_dashboard.py +321 -0
  260. superqode/widgets/resizable_sidebar.py +377 -0
  261. superqode/widgets/response_changes.py +218 -0
  262. superqode/widgets/response_display.py +528 -0
  263. superqode/widgets/rich_tool_display.py +613 -0
  264. superqode/widgets/sidebar_panels.py +1180 -0
  265. superqode/widgets/slash_complete.py +356 -0
  266. superqode/widgets/split_view.py +612 -0
  267. superqode/widgets/status_bar.py +273 -0
  268. superqode/widgets/superqode_display.py +786 -0
  269. superqode/widgets/thinking_display.py +815 -0
  270. superqode/widgets/throbber.py +87 -0
  271. superqode/widgets/toast.py +206 -0
  272. superqode/widgets/unified_output.py +1073 -0
  273. superqode/workspace/__init__.py +75 -0
  274. superqode/workspace/artifacts.py +472 -0
  275. superqode/workspace/coordinator.py +353 -0
  276. superqode/workspace/diff_tracker.py +429 -0
  277. superqode/workspace/git_guard.py +373 -0
  278. superqode/workspace/git_snapshot.py +526 -0
  279. superqode/workspace/manager.py +750 -0
  280. superqode/workspace/snapshot.py +357 -0
  281. superqode/workspace/watcher.py +535 -0
  282. superqode/workspace/worktree.py +440 -0
  283. superqode-0.1.5.dist-info/METADATA +204 -0
  284. superqode-0.1.5.dist-info/RECORD +288 -0
  285. superqode-0.1.5.dist-info/WHEEL +5 -0
  286. superqode-0.1.5.dist-info/entry_points.txt +3 -0
  287. superqode-0.1.5.dist-info/licenses/LICENSE +648 -0
  288. superqode-0.1.5.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1236 @@
1
+ """MCP client manager for SuperQode.
2
+
3
+ This module provides the MCPClientManager class that handles connections
4
+ to multiple MCP servers, tool discovery, and tool execution.
5
+ Implements full MCP protocol support aligned with Zed editor's implementation.
6
+ """
7
+
8
+ import asyncio
9
+ import contextlib
10
+ import logging
11
+ import webbrowser
12
+ from dataclasses import dataclass, field
13
+ from enum import Enum
14
+ from typing import Any, Callable, Optional
15
+
16
+ from superqode.mcp.config import (
17
+ MCPServerConfig,
18
+ MCPStdioConfig,
19
+ MCPHttpConfig,
20
+ MCPSSEConfig,
21
+ load_mcp_config,
22
+ )
23
+ from superqode.mcp.types import (
24
+ MCPTool,
25
+ MCPResource,
26
+ MCPResourceTemplate,
27
+ MCPPrompt,
28
+ MCPPromptArgument,
29
+ MCPToolResult,
30
+ MCPResourceContent,
31
+ MCPPromptResult,
32
+ MCPPromptMessage,
33
+ MCPCompletionResult,
34
+ MCPServerCapabilities,
35
+ MCPServerInfo,
36
+ MCPProgress,
37
+ ServerCapability,
38
+ LoggingLevel,
39
+ ToolAnnotations,
40
+ )
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+ # MCP Protocol version
45
+ LATEST_PROTOCOL_VERSION = "2025-03-26"
46
+ SUPPORTED_PROTOCOL_VERSIONS = [LATEST_PROTOCOL_VERSION, "2024-11-05"]
47
+
48
+
49
+ class MCPConnectionState(Enum):
50
+ """Connection state for an MCP server."""
51
+
52
+ DISCONNECTED = "disconnected"
53
+ CONNECTING = "connecting"
54
+ CONNECTED = "connected"
55
+ ERROR = "error"
56
+ NEEDS_AUTH = "needs_auth" # Waiting for OAuth authentication
57
+
58
+
59
+ @dataclass
60
+ class MCPConnection:
61
+ """Represents a connection to an MCP server.
62
+
63
+ Attributes:
64
+ server_config: The server configuration
65
+ state: Current connection state
66
+ session: The MCP client session (when connected)
67
+ server_info: Server information from initialization
68
+ capabilities: Server capabilities
69
+ tools: Available tools from this server
70
+ resources: Available resources from this server
71
+ resource_templates: Available resource templates
72
+ prompts: Available prompts from this server
73
+ subscribed_resources: Set of subscribed resource URIs
74
+ error_message: Error message if state is ERROR
75
+ """
76
+
77
+ server_config: MCPServerConfig
78
+ state: MCPConnectionState = MCPConnectionState.DISCONNECTED
79
+ session: Any = None # mcp.ClientSession
80
+ server_info: MCPServerInfo | None = None
81
+ capabilities: MCPServerCapabilities = field(default_factory=MCPServerCapabilities)
82
+ tools: list[MCPTool] = field(default_factory=list)
83
+ resources: list[MCPResource] = field(default_factory=list)
84
+ resource_templates: list[MCPResourceTemplate] = field(default_factory=list)
85
+ prompts: list[MCPPrompt] = field(default_factory=list)
86
+ subscribed_resources: set[str] = field(default_factory=set)
87
+ error_message: str | None = None
88
+ _exit_stack: Any = None # contextlib.AsyncExitStack
89
+
90
+
91
+ # Type aliases for callbacks
92
+ StateChangeCallback = Callable[[str, MCPConnectionState], None]
93
+ ToolsChangedCallback = Callable[[str], None]
94
+ ResourcesChangedCallback = Callable[[str], None]
95
+ PromptsChangedCallback = Callable[[str], None]
96
+ ResourceUpdatedCallback = Callable[[str, str], None] # server_id, uri
97
+ ProgressCallback = Callable[[str, MCPProgress], None]
98
+ LogCallback = Callable[[str, LoggingLevel, str, Any], None] # server_id, level, logger, data
99
+
100
+
101
+ class MCPClientManager:
102
+ """Manages connections to multiple MCP servers.
103
+
104
+ This class provides a unified interface for:
105
+ - Connecting to and disconnecting from MCP servers
106
+ - Discovering tools, resources, and prompts
107
+ - Executing tools and reading resources
108
+ - Managing server lifecycle
109
+ - Handling notifications (list changes, resource updates, progress)
110
+ - Resource subscriptions
111
+ - Logging level control
112
+ - Completion requests
113
+
114
+ Example:
115
+ async with MCPClientManager() as manager:
116
+ await manager.load_config()
117
+ await manager.connect_all()
118
+
119
+ tools = manager.list_all_tools()
120
+ result = await manager.call_tool("server_id", "tool_name", {"arg": "value"})
121
+ """
122
+
123
+ def __init__(self) -> None:
124
+ """Initialize the MCP client manager."""
125
+ self._connections: dict[str, MCPConnection] = {}
126
+ self._server_configs: dict[str, MCPServerConfig] = {}
127
+ self._exit_stack: contextlib.AsyncExitStack | None = None
128
+
129
+ # Callbacks
130
+ self._state_callbacks: list[StateChangeCallback] = []
131
+ self._tools_changed_callbacks: list[ToolsChangedCallback] = []
132
+ self._resources_changed_callbacks: list[ResourcesChangedCallback] = []
133
+ self._prompts_changed_callbacks: list[PromptsChangedCallback] = []
134
+ self._resource_updated_callbacks: list[ResourceUpdatedCallback] = []
135
+ self._progress_callbacks: list[ProgressCallback] = []
136
+ self._log_callbacks: list[LogCallback] = []
137
+
138
+ self._initialized = False
139
+
140
+ async def __aenter__(self) -> "MCPClientManager":
141
+ """Enter async context."""
142
+ self._exit_stack = contextlib.AsyncExitStack()
143
+ await self._exit_stack.__aenter__()
144
+ self._initialized = True
145
+ return self
146
+
147
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
148
+ """Exit async context and cleanup all connections."""
149
+ await self.disconnect_all()
150
+ if self._exit_stack:
151
+ await self._exit_stack.__aexit__(exc_type, exc_val, exc_tb)
152
+ self._initialized = False
153
+
154
+ def load_config(self, config_path: Any = None) -> None:
155
+ """Load MCP server configurations from file."""
156
+ self._server_configs = load_mcp_config(config_path)
157
+ logger.info(f"Loaded {len(self._server_configs)} MCP server configurations")
158
+
159
+ def add_server(self, config: MCPServerConfig) -> None:
160
+ """Add a server configuration."""
161
+ self._server_configs[config.id] = config
162
+ logger.debug(f"Added MCP server config: {config.id}")
163
+
164
+ def remove_server(self, server_id: str) -> None:
165
+ """Remove a server configuration."""
166
+ if server_id in self._server_configs:
167
+ del self._server_configs[server_id]
168
+ if server_id in self._connections:
169
+ del self._connections[server_id]
170
+ logger.debug(f"Removed MCP server config: {server_id}")
171
+
172
+ def get_server_configs(self) -> dict[str, MCPServerConfig]:
173
+ """Get all server configurations."""
174
+ return self._server_configs.copy()
175
+
176
+ def get_connection(self, server_id: str) -> MCPConnection | None:
177
+ """Get connection for a server."""
178
+ return self._connections.get(server_id)
179
+
180
+ def get_connection_state(self, server_id: str) -> MCPConnectionState:
181
+ """Get connection state for a server."""
182
+ conn = self._connections.get(server_id)
183
+ return conn.state if conn else MCPConnectionState.DISCONNECTED
184
+
185
+ # Callback registration methods
186
+ def on_state_change(self, callback: StateChangeCallback) -> None:
187
+ """Register callback for connection state changes."""
188
+ self._state_callbacks.append(callback)
189
+
190
+ def on_tools_changed(self, callback: ToolsChangedCallback) -> None:
191
+ """Register callback for tools list changes."""
192
+ self._tools_changed_callbacks.append(callback)
193
+
194
+ def on_resources_changed(self, callback: ResourcesChangedCallback) -> None:
195
+ """Register callback for resources list changes."""
196
+ self._resources_changed_callbacks.append(callback)
197
+
198
+ def on_prompts_changed(self, callback: PromptsChangedCallback) -> None:
199
+ """Register callback for prompts list changes."""
200
+ self._prompts_changed_callbacks.append(callback)
201
+
202
+ def on_resource_updated(self, callback: ResourceUpdatedCallback) -> None:
203
+ """Register callback for resource content updates."""
204
+ self._resource_updated_callbacks.append(callback)
205
+
206
+ def on_progress(self, callback: ProgressCallback) -> None:
207
+ """Register callback for progress notifications."""
208
+ self._progress_callbacks.append(callback)
209
+
210
+ def on_log(self, callback: LogCallback) -> None:
211
+ """Register callback for log messages from servers."""
212
+ self._log_callbacks.append(callback)
213
+
214
+ def _notify_state_change(self, server_id: str, state: MCPConnectionState) -> None:
215
+ """Notify callbacks of state change."""
216
+ for callback in self._state_callbacks:
217
+ try:
218
+ callback(server_id, state)
219
+ except Exception as e:
220
+ logger.warning(f"State change callback error: {e}")
221
+
222
+ def _notify_tools_changed(self, server_id: str) -> None:
223
+ """Notify callbacks of tools list change."""
224
+ for callback in self._tools_changed_callbacks:
225
+ try:
226
+ callback(server_id)
227
+ except Exception as e:
228
+ logger.warning(f"Tools changed callback error: {e}")
229
+
230
+ def _notify_resources_changed(self, server_id: str) -> None:
231
+ """Notify callbacks of resources list change."""
232
+ for callback in self._resources_changed_callbacks:
233
+ try:
234
+ callback(server_id)
235
+ except Exception as e:
236
+ logger.warning(f"Resources changed callback error: {e}")
237
+
238
+ def _notify_prompts_changed(self, server_id: str) -> None:
239
+ """Notify callbacks of prompts list change."""
240
+ for callback in self._prompts_changed_callbacks:
241
+ try:
242
+ callback(server_id)
243
+ except Exception as e:
244
+ logger.warning(f"Prompts changed callback error: {e}")
245
+
246
+ def _notify_resource_updated(self, server_id: str, uri: str) -> None:
247
+ """Notify callbacks of resource content update."""
248
+ for callback in self._resource_updated_callbacks:
249
+ try:
250
+ callback(server_id, uri)
251
+ except Exception as e:
252
+ logger.warning(f"Resource updated callback error: {e}")
253
+
254
+ def _notify_progress(self, server_id: str, progress: MCPProgress) -> None:
255
+ """Notify callbacks of progress update."""
256
+ for callback in self._progress_callbacks:
257
+ try:
258
+ callback(server_id, progress)
259
+ except Exception as e:
260
+ logger.warning(f"Progress callback error: {e}")
261
+
262
+ def _notify_log(self, server_id: str, level: LoggingLevel, logger_name: str, data: Any) -> None:
263
+ """Notify callbacks of log message."""
264
+ for callback in self._log_callbacks:
265
+ try:
266
+ callback(server_id, level, logger_name, data)
267
+ except Exception as e:
268
+ logger.warning(f"Log callback error: {e}")
269
+
270
+ # Server capability checking
271
+ def has_capability(self, server_id: str, capability: ServerCapability) -> bool:
272
+ """Check if a server has a specific capability."""
273
+ conn = self._connections.get(server_id)
274
+ if not conn or conn.state != MCPConnectionState.CONNECTED:
275
+ return False
276
+
277
+ caps = conn.capabilities
278
+ match capability:
279
+ case ServerCapability.EXPERIMENTAL:
280
+ return caps.experimental is not None
281
+ case ServerCapability.LOGGING:
282
+ return caps.logging
283
+ case ServerCapability.PROMPTS:
284
+ return caps.prompts
285
+ case ServerCapability.RESOURCES:
286
+ return caps.resources
287
+ case ServerCapability.TOOLS:
288
+ return caps.tools
289
+ case ServerCapability.COMPLETIONS:
290
+ return caps.completions
291
+ return False
292
+
293
+ async def connect(self, server_id: str) -> bool:
294
+ """Connect to an MCP server."""
295
+ if server_id not in self._server_configs:
296
+ logger.error(f"Unknown MCP server: {server_id}")
297
+ return False
298
+
299
+ config = self._server_configs[server_id]
300
+
301
+ if not config.enabled:
302
+ logger.info(f"MCP server {server_id} is disabled")
303
+ return False
304
+
305
+ # Check if already connected
306
+ existing = self._connections.get(server_id)
307
+ if existing and existing.state == MCPConnectionState.CONNECTED:
308
+ logger.debug(f"Already connected to {server_id}")
309
+ return True
310
+
311
+ # Create connection object
312
+ connection = MCPConnection(server_config=config)
313
+ connection.state = MCPConnectionState.CONNECTING
314
+ self._connections[server_id] = connection
315
+ self._notify_state_change(server_id, MCPConnectionState.CONNECTING)
316
+
317
+ try:
318
+ await self._establish_connection(connection)
319
+ connection.state = MCPConnectionState.CONNECTED
320
+ self._notify_state_change(server_id, MCPConnectionState.CONNECTED)
321
+ logger.info(f"Connected to MCP server: {server_id}")
322
+ return True
323
+ except Exception as e:
324
+ connection.state = MCPConnectionState.ERROR
325
+ connection.error_message = str(e)
326
+ self._notify_state_change(server_id, MCPConnectionState.ERROR)
327
+ logger.error(f"Failed to connect to MCP server {server_id}: {e}")
328
+ return False
329
+
330
+ async def _establish_connection(self, connection: MCPConnection) -> None:
331
+ """Establish connection to an MCP server."""
332
+ try:
333
+ import mcp
334
+ from mcp.client.stdio import StdioServerParameters, stdio_client
335
+ from mcp.client.sse import sse_client
336
+ from mcp.client.streamable_http import streamablehttp_client
337
+ except ImportError as e:
338
+ raise RuntimeError(
339
+ f"MCP package import error: {e}. Install with: pip install mcp>=1.25.0"
340
+ ) from e
341
+
342
+ config = connection.server_config.config
343
+ server_id = connection.server_config.id
344
+ connection._exit_stack = contextlib.AsyncExitStack()
345
+
346
+ try:
347
+ # Create transport based on config type
348
+ if isinstance(config, MCPStdioConfig):
349
+ server_params = StdioServerParameters(
350
+ command=config.command,
351
+ args=config.args,
352
+ env=config.env if config.env else None,
353
+ cwd=config.cwd,
354
+ )
355
+ transport = stdio_client(server_params)
356
+ read_stream, write_stream = await connection._exit_stack.enter_async_context(
357
+ transport
358
+ )
359
+ elif isinstance(config, MCPSSEConfig):
360
+ transport = sse_client(
361
+ url=config.url,
362
+ headers=config.headers if config.headers else None,
363
+ timeout=config.timeout,
364
+ sse_read_timeout=config.sse_read_timeout,
365
+ )
366
+ read_stream, write_stream = await connection._exit_stack.enter_async_context(
367
+ transport
368
+ )
369
+ else: # MCPHttpConfig
370
+ import httpx
371
+ from mcp.shared._httpx_utils import create_mcp_http_client
372
+
373
+ http_client = create_mcp_http_client(
374
+ headers=config.headers if config.headers else None,
375
+ timeout=httpx.Timeout(config.timeout, read=config.sse_read_timeout),
376
+ )
377
+ await connection._exit_stack.enter_async_context(http_client)
378
+
379
+ transport = streamablehttp_client(
380
+ url=config.url,
381
+ http_client=http_client,
382
+ )
383
+ read_stream, write_stream, _ = await connection._exit_stack.enter_async_context(
384
+ transport
385
+ )
386
+
387
+ # Create and initialize session
388
+ session = await connection._exit_stack.enter_async_context(
389
+ mcp.ClientSession(
390
+ read_stream,
391
+ write_stream,
392
+ client_info=mcp.Implementation(
393
+ name="SuperQode",
394
+ version="0.1.0",
395
+ ),
396
+ )
397
+ )
398
+
399
+ # Initialize the connection
400
+ result = await session.initialize()
401
+ connection.session = session
402
+
403
+ # Parse server capabilities
404
+ connection.capabilities = self._parse_capabilities(result.capabilities)
405
+ connection.server_info = MCPServerInfo(
406
+ name=result.serverInfo.name,
407
+ version=result.serverInfo.version,
408
+ capabilities=connection.capabilities,
409
+ protocol_version=str(result.protocolVersion)
410
+ if hasattr(result, "protocolVersion")
411
+ else "",
412
+ )
413
+
414
+ # Discover tools, resources, and prompts
415
+ await self._discover_capabilities(connection)
416
+
417
+ # Set up notification handlers
418
+ self._setup_notification_handlers(connection, server_id)
419
+
420
+ except Exception:
421
+ if connection._exit_stack:
422
+ await connection._exit_stack.aclose()
423
+ connection._exit_stack = None
424
+ raise
425
+
426
+ def _parse_capabilities(self, caps: Any) -> MCPServerCapabilities:
427
+ """Parse server capabilities from initialization response."""
428
+ if caps is None:
429
+ return MCPServerCapabilities()
430
+
431
+ result = MCPServerCapabilities()
432
+
433
+ if hasattr(caps, "experimental") and caps.experimental:
434
+ result.experimental = dict(caps.experimental)
435
+
436
+ if hasattr(caps, "logging") and caps.logging:
437
+ result.logging = True
438
+
439
+ if hasattr(caps, "prompts") and caps.prompts:
440
+ result.prompts = True
441
+ if hasattr(caps.prompts, "listChanged"):
442
+ result.prompts_list_changed = bool(caps.prompts.listChanged)
443
+
444
+ if hasattr(caps, "resources") and caps.resources:
445
+ result.resources = True
446
+ if hasattr(caps.resources, "subscribe"):
447
+ result.resources_subscribe = bool(caps.resources.subscribe)
448
+ if hasattr(caps.resources, "listChanged"):
449
+ result.resources_list_changed = bool(caps.resources.listChanged)
450
+
451
+ if hasattr(caps, "tools") and caps.tools:
452
+ result.tools = True
453
+ if hasattr(caps.tools, "listChanged"):
454
+ result.tools_list_changed = bool(caps.tools.listChanged)
455
+
456
+ if hasattr(caps, "completions") and caps.completions:
457
+ result.completions = True
458
+
459
+ return result
460
+
461
+ def _setup_notification_handlers(self, connection: MCPConnection, server_id: str) -> None:
462
+ """Set up handlers for server notifications."""
463
+ # Note: The MCP Python SDK handles notifications through callbacks
464
+ # This is a placeholder for when we need to handle specific notifications
465
+ pass
466
+
467
+ async def _discover_capabilities(self, connection: MCPConnection) -> None:
468
+ """Discover tools, resources, and prompts from a connected server."""
469
+ session = connection.session
470
+ server_id = connection.server_config.id
471
+
472
+ # Discover tools
473
+ if connection.capabilities.tools:
474
+ try:
475
+ tools_result = await session.list_tools()
476
+ connection.tools = [
477
+ self._parse_tool(tool, server_id) for tool in tools_result.tools
478
+ ]
479
+ logger.debug(f"Discovered {len(connection.tools)} tools from {server_id}")
480
+ except Exception as e:
481
+ logger.warning(f"Failed to list tools from {server_id}: {e}")
482
+ connection.tools = []
483
+
484
+ # Discover resources
485
+ if connection.capabilities.resources:
486
+ try:
487
+ resources_result = await session.list_resources()
488
+ connection.resources = [
489
+ MCPResource(
490
+ uri=str(resource.uri),
491
+ name=resource.name,
492
+ description=resource.description,
493
+ mime_type=getattr(resource, "mimeType", None),
494
+ server_id=server_id,
495
+ )
496
+ for resource in resources_result.resources
497
+ ]
498
+ logger.debug(f"Discovered {len(connection.resources)} resources from {server_id}")
499
+ except Exception as e:
500
+ logger.warning(f"Failed to list resources from {server_id}: {e}")
501
+ connection.resources = []
502
+
503
+ # Discover resource templates
504
+ try:
505
+ templates_result = await session.list_resource_templates()
506
+ connection.resource_templates = [
507
+ MCPResourceTemplate(
508
+ uri_template=template.uriTemplate,
509
+ name=template.name,
510
+ description=template.description,
511
+ mime_type=getattr(template, "mimeType", None),
512
+ server_id=server_id,
513
+ )
514
+ for template in templates_result.resourceTemplates
515
+ ]
516
+ logger.debug(
517
+ f"Discovered {len(connection.resource_templates)} resource templates from {server_id}"
518
+ )
519
+ except Exception as e:
520
+ logger.debug(f"No resource templates from {server_id}: {e}")
521
+ connection.resource_templates = []
522
+
523
+ # Discover prompts
524
+ if connection.capabilities.prompts:
525
+ try:
526
+ prompts_result = await session.list_prompts()
527
+ connection.prompts = [
528
+ MCPPrompt(
529
+ name=prompt.name,
530
+ description=prompt.description,
531
+ arguments=[
532
+ MCPPromptArgument(
533
+ name=arg.name,
534
+ description=arg.description,
535
+ required=getattr(arg, "required", False),
536
+ )
537
+ for arg in (prompt.arguments or [])
538
+ ],
539
+ server_id=server_id,
540
+ )
541
+ for prompt in prompts_result.prompts
542
+ ]
543
+ logger.debug(f"Discovered {len(connection.prompts)} prompts from {server_id}")
544
+ except Exception as e:
545
+ logger.warning(f"Failed to list prompts from {server_id}: {e}")
546
+ connection.prompts = []
547
+
548
+ def _parse_tool(self, tool: Any, server_id: str) -> MCPTool:
549
+ """Parse a tool from the MCP response."""
550
+ annotations = None
551
+ if hasattr(tool, "annotations") and tool.annotations:
552
+ annotations = ToolAnnotations(
553
+ title=getattr(tool.annotations, "title", None),
554
+ read_only_hint=getattr(tool.annotations, "readOnlyHint", None),
555
+ destructive_hint=getattr(tool.annotations, "destructiveHint", None),
556
+ idempotent_hint=getattr(tool.annotations, "idempotentHint", None),
557
+ open_world_hint=getattr(tool.annotations, "openWorldHint", None),
558
+ )
559
+
560
+ return MCPTool(
561
+ name=tool.name,
562
+ description=tool.description or "",
563
+ input_schema=tool.inputSchema if hasattr(tool, "inputSchema") else {},
564
+ output_schema=getattr(tool, "outputSchema", None),
565
+ server_id=server_id,
566
+ annotations=annotations,
567
+ )
568
+
569
+ async def disconnect(self, server_id: str) -> None:
570
+ """Disconnect from an MCP server."""
571
+ connection = self._connections.get(server_id)
572
+ if not connection:
573
+ return
574
+
575
+ # Mark as disconnected first to prevent race conditions
576
+ old_state = connection.state
577
+ connection.state = MCPConnectionState.DISCONNECTED
578
+ connection.session = None
579
+
580
+ if connection._exit_stack:
581
+ try:
582
+ # Give the exit stack a chance to clean up with a timeout
583
+ import asyncio
584
+
585
+ await asyncio.wait_for(connection._exit_stack.aclose(), timeout=5.0)
586
+ except asyncio.TimeoutError:
587
+ logger.warning(f"Timeout closing connection to {server_id}")
588
+ except asyncio.CancelledError:
589
+ logger.debug(f"Disconnect cancelled for {server_id}")
590
+ except RuntimeError as e:
591
+ # Handle anyio cancel scope issues gracefully
592
+ if "cancel scope" in str(e).lower():
593
+ logger.debug(f"Cancel scope issue during disconnect from {server_id}: {e}")
594
+ else:
595
+ logger.warning(f"Runtime error closing connection to {server_id}: {e}")
596
+ except Exception as e:
597
+ logger.warning(f"Error closing connection to {server_id}: {e}")
598
+ finally:
599
+ connection._exit_stack = None
600
+
601
+ connection.tools = []
602
+ connection.resources = []
603
+ connection.resource_templates = []
604
+ connection.prompts = []
605
+ connection.subscribed_resources.clear()
606
+
607
+ if old_state != MCPConnectionState.DISCONNECTED:
608
+ self._notify_state_change(server_id, MCPConnectionState.DISCONNECTED)
609
+ logger.info(f"Disconnected from MCP server: {server_id}")
610
+
611
+ async def connect_all(self) -> dict[str, bool]:
612
+ """Connect to all enabled servers with auto_connect=True."""
613
+ results = {}
614
+ for server_id, config in self._server_configs.items():
615
+ if config.enabled and config.auto_connect:
616
+ results[server_id] = await self.connect(server_id)
617
+ return results
618
+
619
+ async def disconnect_all(self) -> None:
620
+ """Disconnect from all connected servers."""
621
+ # Must disconnect sequentially - anyio cancel scopes must be exited
622
+ # in the same task they were entered
623
+ for server_id in list(self._connections.keys()):
624
+ try:
625
+ await self.disconnect(server_id)
626
+ except Exception as e:
627
+ logger.warning(f"Error disconnecting from {server_id}: {e}")
628
+
629
+ async def reconnect(self, server_id: str) -> bool:
630
+ """Reconnect to an MCP server."""
631
+ await self.disconnect(server_id)
632
+ return await self.connect(server_id)
633
+
634
+ async def restart_server(self, server_id: str) -> bool:
635
+ """Restart an MCP server (stop and start)."""
636
+ return await self.reconnect(server_id)
637
+
638
+ # Tool operations
639
+
640
+ def list_all_tools(self) -> list[MCPTool]:
641
+ """List all available tools from all connected servers."""
642
+ tools = []
643
+ for connection in self._connections.values():
644
+ if connection.state == MCPConnectionState.CONNECTED:
645
+ tools.extend(connection.tools)
646
+ return tools
647
+
648
+ def get_tool(self, server_id: str, tool_name: str) -> MCPTool | None:
649
+ """Get a specific tool by server ID and name."""
650
+ connection = self._connections.get(server_id)
651
+ if not connection or connection.state != MCPConnectionState.CONNECTED:
652
+ return None
653
+
654
+ for tool in connection.tools:
655
+ if tool.name == tool_name:
656
+ return tool
657
+ return None
658
+
659
+ def find_tool(self, tool_name: str) -> tuple[str, MCPTool] | None:
660
+ """Find a tool by name across all servers."""
661
+ for server_id, connection in self._connections.items():
662
+ if connection.state != MCPConnectionState.CONNECTED:
663
+ continue
664
+ for tool in connection.tools:
665
+ if tool.name == tool_name:
666
+ return (server_id, tool)
667
+ return None
668
+
669
+ async def call_tool(
670
+ self,
671
+ server_id: str,
672
+ tool_name: str,
673
+ arguments: dict[str, Any],
674
+ timeout: float | None = None,
675
+ ) -> MCPToolResult:
676
+ """Execute a tool on an MCP server."""
677
+ connection = self._connections.get(server_id)
678
+ if not connection or connection.state != MCPConnectionState.CONNECTED:
679
+ return MCPToolResult(
680
+ content=[],
681
+ is_error=True,
682
+ error_message=f"Not connected to server: {server_id}",
683
+ )
684
+
685
+ try:
686
+ result = await connection.session.call_tool(tool_name, arguments)
687
+
688
+ # Convert content to dict format
689
+ content = []
690
+ for item in result.content:
691
+ if hasattr(item, "text"):
692
+ content.append({"type": "text", "text": item.text})
693
+ elif hasattr(item, "data") and hasattr(item, "mimeType"):
694
+ if "image" in getattr(item, "mimeType", ""):
695
+ content.append(
696
+ {
697
+ "type": "image",
698
+ "data": item.data,
699
+ "mimeType": item.mimeType,
700
+ }
701
+ )
702
+ elif "audio" in getattr(item, "mimeType", ""):
703
+ content.append(
704
+ {
705
+ "type": "audio",
706
+ "data": item.data,
707
+ "mimeType": item.mimeType,
708
+ }
709
+ )
710
+ elif hasattr(item, "resource"):
711
+ content.append(
712
+ {
713
+ "type": "resource",
714
+ "uri": str(item.resource.uri),
715
+ "mimeType": getattr(item.resource, "mimeType", None),
716
+ }
717
+ )
718
+ else:
719
+ content.append({"type": "unknown", "data": str(item)})
720
+
721
+ return MCPToolResult(
722
+ content=content,
723
+ is_error=getattr(result, "isError", False),
724
+ structured_content=getattr(result, "structuredContent", None),
725
+ )
726
+ except Exception as e:
727
+ logger.error(f"Tool execution failed: {e}")
728
+ return MCPToolResult(
729
+ content=[],
730
+ is_error=True,
731
+ error_message=str(e),
732
+ )
733
+
734
+ # Resource operations
735
+
736
+ def list_all_resources(self) -> list[MCPResource]:
737
+ """List all available resources from all connected servers."""
738
+ resources = []
739
+ for connection in self._connections.values():
740
+ if connection.state == MCPConnectionState.CONNECTED:
741
+ resources.extend(connection.resources)
742
+ return resources
743
+
744
+ def list_all_resource_templates(self) -> list[MCPResourceTemplate]:
745
+ """List all available resource templates from all connected servers."""
746
+ templates = []
747
+ for connection in self._connections.values():
748
+ if connection.state == MCPConnectionState.CONNECTED:
749
+ templates.extend(connection.resource_templates)
750
+ return templates
751
+
752
+ async def read_resource(self, server_id: str, uri: str) -> MCPResourceContent | None:
753
+ """Read a resource from an MCP server."""
754
+ connection = self._connections.get(server_id)
755
+ if not connection or connection.state != MCPConnectionState.CONNECTED:
756
+ return None
757
+
758
+ try:
759
+ result = await connection.session.read_resource(uri)
760
+
761
+ if result.contents:
762
+ content = result.contents[0]
763
+ return MCPResourceContent(
764
+ uri=uri,
765
+ mime_type=getattr(content, "mimeType", None),
766
+ text=getattr(content, "text", None),
767
+ blob=getattr(content, "blob", None),
768
+ )
769
+ return None
770
+ except Exception as e:
771
+ logger.error(f"Failed to read resource {uri}: {e}")
772
+ return None
773
+
774
+ async def subscribe_resource(self, server_id: str, uri: str) -> bool:
775
+ """Subscribe to resource updates."""
776
+ connection = self._connections.get(server_id)
777
+ if not connection or connection.state != MCPConnectionState.CONNECTED:
778
+ return False
779
+
780
+ if not connection.capabilities.resources_subscribe:
781
+ logger.warning(f"Server {server_id} does not support resource subscriptions")
782
+ return False
783
+
784
+ try:
785
+ await connection.session.subscribe_resource(uri)
786
+ connection.subscribed_resources.add(uri)
787
+ return True
788
+ except Exception as e:
789
+ logger.error(f"Failed to subscribe to resource {uri}: {e}")
790
+ return False
791
+
792
+ async def unsubscribe_resource(self, server_id: str, uri: str) -> bool:
793
+ """Unsubscribe from resource updates."""
794
+ connection = self._connections.get(server_id)
795
+ if not connection or connection.state != MCPConnectionState.CONNECTED:
796
+ return False
797
+
798
+ try:
799
+ await connection.session.unsubscribe_resource(uri)
800
+ connection.subscribed_resources.discard(uri)
801
+ return True
802
+ except Exception as e:
803
+ logger.error(f"Failed to unsubscribe from resource {uri}: {e}")
804
+ return False
805
+
806
+ # Prompt operations
807
+
808
+ def list_all_prompts(self) -> list[MCPPrompt]:
809
+ """List all available prompts from all connected servers."""
810
+ prompts = []
811
+ for connection in self._connections.values():
812
+ if connection.state == MCPConnectionState.CONNECTED:
813
+ prompts.extend(connection.prompts)
814
+ return prompts
815
+
816
+ async def get_prompt(
817
+ self,
818
+ server_id: str,
819
+ prompt_name: str,
820
+ arguments: dict[str, str] | None = None,
821
+ ) -> MCPPromptResult | None:
822
+ """Get a prompt from an MCP server."""
823
+ connection = self._connections.get(server_id)
824
+ if not connection or connection.state != MCPConnectionState.CONNECTED:
825
+ return None
826
+
827
+ try:
828
+ result = await connection.session.get_prompt(prompt_name, arguments or {})
829
+
830
+ messages = []
831
+ for msg in result.messages:
832
+ role = msg.role
833
+ if hasattr(msg.content, "text"):
834
+ content = msg.content.text
835
+ else:
836
+ content = str(msg.content)
837
+ messages.append(MCPPromptMessage(role=role, content=content))
838
+
839
+ return MCPPromptResult(
840
+ description=result.description,
841
+ messages=messages,
842
+ )
843
+ except Exception as e:
844
+ logger.error(f"Failed to get prompt {prompt_name}: {e}")
845
+ return None
846
+
847
+ # Completion operations
848
+
849
+ async def complete_prompt_argument(
850
+ self,
851
+ server_id: str,
852
+ prompt_name: str,
853
+ argument_name: str,
854
+ argument_value: str,
855
+ ) -> MCPCompletionResult | None:
856
+ """Get completions for a prompt argument."""
857
+ connection = self._connections.get(server_id)
858
+ if not connection or connection.state != MCPConnectionState.CONNECTED:
859
+ return None
860
+
861
+ if not connection.capabilities.completions:
862
+ return None
863
+
864
+ try:
865
+ result = await connection.session.complete(
866
+ ref={"type": "ref/prompt", "name": prompt_name},
867
+ argument={"name": argument_name, "value": argument_value},
868
+ )
869
+
870
+ return MCPCompletionResult(
871
+ values=result.completion.values,
872
+ total=getattr(result.completion, "total", None),
873
+ has_more=getattr(result.completion, "hasMore", None),
874
+ )
875
+ except Exception as e:
876
+ logger.error(f"Failed to get completions: {e}")
877
+ return None
878
+
879
+ async def complete_resource_argument(
880
+ self,
881
+ server_id: str,
882
+ uri: str,
883
+ argument_name: str,
884
+ argument_value: str,
885
+ ) -> MCPCompletionResult | None:
886
+ """Get completions for a resource argument."""
887
+ connection = self._connections.get(server_id)
888
+ if not connection or connection.state != MCPConnectionState.CONNECTED:
889
+ return None
890
+
891
+ if not connection.capabilities.completions:
892
+ return None
893
+
894
+ try:
895
+ result = await connection.session.complete(
896
+ ref={"type": "ref/resource", "uri": uri},
897
+ argument={"name": argument_name, "value": argument_value},
898
+ )
899
+
900
+ return MCPCompletionResult(
901
+ values=result.completion.values,
902
+ total=getattr(result.completion, "total", None),
903
+ has_more=getattr(result.completion, "hasMore", None),
904
+ )
905
+ except Exception as e:
906
+ logger.error(f"Failed to get completions: {e}")
907
+ return None
908
+
909
+ # Logging operations
910
+
911
+ async def set_logging_level(self, server_id: str, level: LoggingLevel) -> bool:
912
+ """Set the logging level for a server."""
913
+ connection = self._connections.get(server_id)
914
+ if not connection or connection.state != MCPConnectionState.CONNECTED:
915
+ return False
916
+
917
+ if not connection.capabilities.logging:
918
+ logger.warning(f"Server {server_id} does not support logging")
919
+ return False
920
+
921
+ try:
922
+ await connection.session.set_logging_level(level.value)
923
+ return True
924
+ except Exception as e:
925
+ logger.error(f"Failed to set logging level: {e}")
926
+ return False
927
+
928
+ # Refresh operations (for list_changed notifications)
929
+
930
+ async def refresh_tools(self, server_id: str) -> None:
931
+ """Refresh the tools list for a server."""
932
+ connection = self._connections.get(server_id)
933
+ if not connection or connection.state != MCPConnectionState.CONNECTED:
934
+ return
935
+
936
+ if not connection.capabilities.tools:
937
+ return
938
+
939
+ try:
940
+ tools_result = await connection.session.list_tools()
941
+ connection.tools = [self._parse_tool(tool, server_id) for tool in tools_result.tools]
942
+ self._notify_tools_changed(server_id)
943
+ except Exception as e:
944
+ logger.error(f"Failed to refresh tools: {e}")
945
+
946
+ async def refresh_resources(self, server_id: str) -> None:
947
+ """Refresh the resources list for a server."""
948
+ connection = self._connections.get(server_id)
949
+ if not connection or connection.state != MCPConnectionState.CONNECTED:
950
+ return
951
+
952
+ if not connection.capabilities.resources:
953
+ return
954
+
955
+ try:
956
+ resources_result = await connection.session.list_resources()
957
+ connection.resources = [
958
+ MCPResource(
959
+ uri=str(resource.uri),
960
+ name=resource.name,
961
+ description=resource.description,
962
+ mime_type=getattr(resource, "mimeType", None),
963
+ server_id=server_id,
964
+ )
965
+ for resource in resources_result.resources
966
+ ]
967
+ self._notify_resources_changed(server_id)
968
+ except Exception as e:
969
+ logger.error(f"Failed to refresh resources: {e}")
970
+
971
+ async def refresh_prompts(self, server_id: str) -> None:
972
+ """Refresh the prompts list for a server."""
973
+ connection = self._connections.get(server_id)
974
+ if not connection or connection.state != MCPConnectionState.CONNECTED:
975
+ return
976
+
977
+ if not connection.capabilities.prompts:
978
+ return
979
+
980
+ try:
981
+ prompts_result = await connection.session.list_prompts()
982
+ connection.prompts = [
983
+ MCPPrompt(
984
+ name=prompt.name,
985
+ description=prompt.description,
986
+ arguments=[
987
+ MCPPromptArgument(
988
+ name=arg.name,
989
+ description=arg.description,
990
+ required=getattr(arg, "required", False),
991
+ )
992
+ for arg in (prompt.arguments or [])
993
+ ],
994
+ server_id=server_id,
995
+ )
996
+ for prompt in prompts_result.prompts
997
+ ]
998
+ self._notify_prompts_changed(server_id)
999
+ except Exception as e:
1000
+ logger.error(f"Failed to refresh prompts: {e}")
1001
+
1002
+ # Utility methods
1003
+
1004
+ def get_status_summary(self) -> dict[str, Any]:
1005
+ """Get a summary of all server connection statuses."""
1006
+ return {
1007
+ "total_servers": len(self._server_configs),
1008
+ "connected": sum(
1009
+ 1 for c in self._connections.values() if c.state == MCPConnectionState.CONNECTED
1010
+ ),
1011
+ "total_tools": len(self.list_all_tools()),
1012
+ "total_resources": len(self.list_all_resources()),
1013
+ "total_resource_templates": len(self.list_all_resource_templates()),
1014
+ "total_prompts": len(self.list_all_prompts()),
1015
+ "servers": {
1016
+ server_id: {
1017
+ "state": conn.state.value,
1018
+ "server_name": conn.server_info.name if conn.server_info else None,
1019
+ "server_version": conn.server_info.version if conn.server_info else None,
1020
+ "tools": len(conn.tools),
1021
+ "resources": len(conn.resources),
1022
+ "resource_templates": len(conn.resource_templates),
1023
+ "prompts": len(conn.prompts),
1024
+ "subscribed_resources": len(conn.subscribed_resources),
1025
+ "capabilities": {
1026
+ "logging": conn.capabilities.logging,
1027
+ "prompts": conn.capabilities.prompts,
1028
+ "resources": conn.capabilities.resources,
1029
+ "tools": conn.capabilities.tools,
1030
+ "completions": conn.capabilities.completions,
1031
+ },
1032
+ "error": conn.error_message,
1033
+ }
1034
+ for server_id, conn in self._connections.items()
1035
+ },
1036
+ }
1037
+
1038
+ def get_server_info(self, server_id: str) -> MCPServerInfo | None:
1039
+ """Get server information for a connected server."""
1040
+ connection = self._connections.get(server_id)
1041
+ if connection and connection.state == MCPConnectionState.CONNECTED:
1042
+ return connection.server_info
1043
+ return None
1044
+
1045
+ def configured_server_ids(self) -> list[str]:
1046
+ """Get list of configured server IDs (enabled only)."""
1047
+ return [server_id for server_id, config in self._server_configs.items() if config.enabled]
1048
+
1049
+ def running_server_ids(self) -> list[str]:
1050
+ """Get list of currently running server IDs."""
1051
+ return [
1052
+ server_id
1053
+ for server_id, conn in self._connections.items()
1054
+ if conn.state == MCPConnectionState.CONNECTED
1055
+ ]
1056
+
1057
+ # ========================================================================
1058
+ # OAuth Support Methods
1059
+ # ========================================================================
1060
+
1061
+ async def authenticate_server(self, server_id: str) -> bool:
1062
+ """
1063
+ Perform OAuth authentication for a server.
1064
+
1065
+ Opens browser for user authentication and waits for callback.
1066
+ Returns True if authentication succeeds.
1067
+ """
1068
+ config = self._server_configs.get(server_id)
1069
+ if not config:
1070
+ logger.error(f"Unknown server: {server_id}")
1071
+ return False
1072
+
1073
+ # Only HTTP/SSE configs might need OAuth
1074
+ if isinstance(config.config, MCPStdioConfig):
1075
+ logger.debug(f"Server {server_id} uses stdio, OAuth not applicable")
1076
+ return True
1077
+
1078
+ try:
1079
+ from superqode.mcp.oauth import MCPOAuthProvider, OAuthConfig
1080
+ from superqode.mcp.oauth_callback import get_callback_server
1081
+ from superqode.mcp.auth_storage import get_auth_storage
1082
+
1083
+ # Get server URL
1084
+ if isinstance(config.config, (MCPHttpConfig, MCPSSEConfig)):
1085
+ server_url = config.config.url
1086
+ else:
1087
+ return True # Non-HTTP doesn't need OAuth
1088
+
1089
+ # Check for existing valid tokens
1090
+ storage = get_auth_storage()
1091
+ existing_tokens = storage.load_tokens(server_url)
1092
+ if existing_tokens and not existing_tokens.is_expired():
1093
+ logger.debug(f"Using existing tokens for {server_id}")
1094
+ return True
1095
+
1096
+ # Try to refresh if we have a refresh token
1097
+ if existing_tokens and existing_tokens.refresh_token:
1098
+ try:
1099
+ provider = MCPOAuthProvider()
1100
+ new_tokens = await provider.refresh_tokens(
1101
+ existing_tokens.refresh_token,
1102
+ server_url,
1103
+ )
1104
+ storage.save_tokens(server_url, new_tokens)
1105
+ logger.info(f"Refreshed tokens for {server_id}")
1106
+ return True
1107
+ except Exception as e:
1108
+ logger.debug(f"Token refresh failed: {e}")
1109
+
1110
+ # Start OAuth flow
1111
+ callback_server = await get_callback_server()
1112
+ oauth_config = OAuthConfig(
1113
+ redirect_uri=callback_server.get_redirect_uri(),
1114
+ )
1115
+ provider = MCPOAuthProvider(oauth_config)
1116
+
1117
+ # Get authorization URL
1118
+ auth_url = await provider.start_auth_flow(server_url)
1119
+
1120
+ # Update connection state
1121
+ connection = self._connections.get(server_id)
1122
+ if connection:
1123
+ connection.state = MCPConnectionState.NEEDS_AUTH
1124
+ self._notify_state_change(server_id, MCPConnectionState.NEEDS_AUTH)
1125
+
1126
+ # Open browser for authentication
1127
+ logger.info(f"Opening browser for {server_id} authentication")
1128
+ webbrowser.open(auth_url)
1129
+
1130
+ # Wait for callback
1131
+ # Extract state from URL
1132
+ import urllib.parse
1133
+
1134
+ parsed = urllib.parse.urlparse(auth_url)
1135
+ params = urllib.parse.parse_qs(parsed.query)
1136
+ state = params.get("state", [None])[0]
1137
+
1138
+ if not state:
1139
+ logger.error("Failed to extract state from auth URL")
1140
+ return False
1141
+
1142
+ result = await callback_server.wait_for_callback(state, timeout=300)
1143
+
1144
+ if result.error:
1145
+ logger.error(f"OAuth error: {result.error} - {result.error_description}")
1146
+ return False
1147
+
1148
+ if not result.code:
1149
+ logger.error("No authorization code received")
1150
+ return False
1151
+
1152
+ # Exchange code for tokens
1153
+ tokens = await provider.handle_callback(result.code, result.state)
1154
+
1155
+ # Store tokens
1156
+ storage.save_tokens(server_url, tokens)
1157
+ logger.info(f"OAuth authentication successful for {server_id}")
1158
+
1159
+ return True
1160
+
1161
+ except ImportError as e:
1162
+ logger.warning(f"OAuth dependencies not available: {e}")
1163
+ return True # Continue without OAuth
1164
+ except Exception as e:
1165
+ logger.error(f"OAuth authentication failed for {server_id}: {e}")
1166
+ return False
1167
+
1168
+ async def get_auth_headers(self, server_id: str) -> dict[str, str]:
1169
+ """
1170
+ Get authentication headers for a server.
1171
+
1172
+ Returns headers dict with Authorization if tokens are available.
1173
+ """
1174
+ config = self._server_configs.get(server_id)
1175
+ if not config:
1176
+ return {}
1177
+
1178
+ if isinstance(config.config, MCPStdioConfig):
1179
+ return {}
1180
+
1181
+ try:
1182
+ from superqode.mcp.auth_storage import get_auth_storage
1183
+
1184
+ # Get server URL
1185
+ if isinstance(config.config, (MCPHttpConfig, MCPSSEConfig)):
1186
+ server_url = config.config.url
1187
+ else:
1188
+ return {}
1189
+
1190
+ storage = get_auth_storage()
1191
+
1192
+ # Try OAuth tokens first
1193
+ tokens = storage.load_tokens(server_url)
1194
+ if tokens and not tokens.is_expired():
1195
+ return {"Authorization": f"{tokens.token_type} {tokens.access_token}"}
1196
+
1197
+ # Try bearer token
1198
+ bearer = storage.load_bearer_token(server_url)
1199
+ if bearer:
1200
+ return {"Authorization": f"Bearer {bearer}"}
1201
+
1202
+ # Try API key
1203
+ api_key = storage.load_api_key(server_url)
1204
+ if api_key:
1205
+ return {"X-API-Key": api_key}
1206
+
1207
+ return {}
1208
+
1209
+ except ImportError:
1210
+ return {}
1211
+ except Exception as e:
1212
+ logger.debug(f"Error getting auth headers: {e}")
1213
+ return {}
1214
+
1215
+ def needs_authentication(self, server_id: str) -> bool:
1216
+ """Check if a server is waiting for authentication."""
1217
+ connection = self._connections.get(server_id)
1218
+ return connection is not None and connection.state == MCPConnectionState.NEEDS_AUTH
1219
+
1220
+ async def clear_server_credentials(self, server_id: str) -> None:
1221
+ """Clear stored credentials for a server."""
1222
+ config = self._server_configs.get(server_id)
1223
+ if not config:
1224
+ return
1225
+
1226
+ try:
1227
+ from superqode.mcp.auth_storage import get_auth_storage
1228
+
1229
+ if isinstance(config.config, (MCPHttpConfig, MCPSSEConfig)):
1230
+ storage = get_auth_storage()
1231
+ storage.clear_tokens(config.config.url)
1232
+ logger.info(f"Cleared credentials for {server_id}")
1233
+ except ImportError:
1234
+ pass
1235
+ except Exception as e:
1236
+ logger.warning(f"Error clearing credentials: {e}")