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.
- superqode/__init__.py +33 -0
- superqode/acp/__init__.py +23 -0
- superqode/acp/client.py +913 -0
- superqode/acp/permission_screen.py +457 -0
- superqode/acp/types.py +480 -0
- superqode/acp_discovery.py +856 -0
- superqode/agent/__init__.py +22 -0
- superqode/agent/edit_strategies.py +334 -0
- superqode/agent/loop.py +892 -0
- superqode/agent/qe_report_templates.py +39 -0
- superqode/agent/system_prompts.py +353 -0
- superqode/agent_output.py +721 -0
- superqode/agent_stream.py +953 -0
- superqode/agents/__init__.py +59 -0
- superqode/agents/acp_registry.py +305 -0
- superqode/agents/client.py +249 -0
- superqode/agents/data/augmentcode.com.toml +51 -0
- superqode/agents/data/cagent.dev.toml +51 -0
- superqode/agents/data/claude.com.toml +60 -0
- superqode/agents/data/codeassistant.dev.toml +51 -0
- superqode/agents/data/codex.openai.com.toml +57 -0
- superqode/agents/data/fastagent.ai.toml +66 -0
- superqode/agents/data/geminicli.com.toml +77 -0
- superqode/agents/data/goose.block.xyz.toml +54 -0
- superqode/agents/data/junie.jetbrains.com.toml +56 -0
- superqode/agents/data/kimi.moonshot.cn.toml +57 -0
- superqode/agents/data/llmlingagent.dev.toml +51 -0
- superqode/agents/data/molt.bot.toml +49 -0
- superqode/agents/data/opencode.ai.toml +60 -0
- superqode/agents/data/stakpak.dev.toml +51 -0
- superqode/agents/data/vtcode.dev.toml +51 -0
- superqode/agents/discovery.py +266 -0
- superqode/agents/messaging.py +160 -0
- superqode/agents/persona.py +166 -0
- superqode/agents/registry.py +421 -0
- superqode/agents/schema.py +72 -0
- superqode/agents/unified.py +367 -0
- superqode/app/__init__.py +111 -0
- superqode/app/constants.py +314 -0
- superqode/app/css.py +366 -0
- superqode/app/models.py +118 -0
- superqode/app/suggester.py +125 -0
- superqode/app/widgets.py +1591 -0
- superqode/app_enhanced.py +399 -0
- superqode/app_main.py +17187 -0
- superqode/approval.py +312 -0
- superqode/atomic.py +296 -0
- superqode/commands/__init__.py +1 -0
- superqode/commands/acp.py +965 -0
- superqode/commands/agents.py +180 -0
- superqode/commands/auth.py +278 -0
- superqode/commands/config.py +374 -0
- superqode/commands/init.py +826 -0
- superqode/commands/providers.py +819 -0
- superqode/commands/qe.py +1145 -0
- superqode/commands/roles.py +380 -0
- superqode/commands/serve.py +172 -0
- superqode/commands/suggestions.py +127 -0
- superqode/commands/superqe.py +460 -0
- superqode/config/__init__.py +51 -0
- superqode/config/loader.py +812 -0
- superqode/config/schema.py +498 -0
- superqode/core/__init__.py +111 -0
- superqode/core/roles.py +281 -0
- superqode/danger.py +386 -0
- superqode/data/superqode-template.yaml +1522 -0
- superqode/design_system.py +1080 -0
- superqode/dialogs/__init__.py +6 -0
- superqode/dialogs/base.py +39 -0
- superqode/dialogs/model.py +130 -0
- superqode/dialogs/provider.py +870 -0
- superqode/diff_view.py +919 -0
- superqode/enterprise.py +21 -0
- superqode/evaluation/__init__.py +25 -0
- superqode/evaluation/adapters.py +93 -0
- superqode/evaluation/behaviors.py +89 -0
- superqode/evaluation/engine.py +209 -0
- superqode/evaluation/scenarios.py +96 -0
- superqode/execution/__init__.py +36 -0
- superqode/execution/linter.py +538 -0
- superqode/execution/modes.py +347 -0
- superqode/execution/resolver.py +283 -0
- superqode/execution/runner.py +642 -0
- superqode/file_explorer.py +811 -0
- superqode/file_viewer.py +471 -0
- superqode/flash.py +183 -0
- superqode/guidance/__init__.py +58 -0
- superqode/guidance/config.py +203 -0
- superqode/guidance/prompts.py +71 -0
- superqode/harness/__init__.py +54 -0
- superqode/harness/accelerator.py +291 -0
- superqode/harness/config.py +319 -0
- superqode/harness/validator.py +147 -0
- superqode/history.py +279 -0
- superqode/integrations/superopt_runner.py +124 -0
- superqode/logging/__init__.py +49 -0
- superqode/logging/adapters.py +219 -0
- superqode/logging/formatter.py +923 -0
- superqode/logging/integration.py +341 -0
- superqode/logging/sinks.py +170 -0
- superqode/logging/unified_log.py +417 -0
- superqode/lsp/__init__.py +26 -0
- superqode/lsp/client.py +544 -0
- superqode/main.py +1069 -0
- superqode/mcp/__init__.py +89 -0
- superqode/mcp/auth_storage.py +380 -0
- superqode/mcp/client.py +1236 -0
- superqode/mcp/config.py +319 -0
- superqode/mcp/integration.py +337 -0
- superqode/mcp/oauth.py +436 -0
- superqode/mcp/oauth_callback.py +385 -0
- superqode/mcp/types.py +290 -0
- superqode/memory/__init__.py +31 -0
- superqode/memory/feedback.py +342 -0
- superqode/memory/store.py +522 -0
- superqode/notifications.py +369 -0
- superqode/optimization/__init__.py +5 -0
- superqode/optimization/config.py +33 -0
- superqode/permissions/__init__.py +25 -0
- superqode/permissions/rules.py +488 -0
- superqode/plan.py +323 -0
- superqode/providers/__init__.py +33 -0
- superqode/providers/gateway/__init__.py +165 -0
- superqode/providers/gateway/base.py +228 -0
- superqode/providers/gateway/litellm_gateway.py +1170 -0
- superqode/providers/gateway/openresponses_gateway.py +436 -0
- superqode/providers/health.py +297 -0
- superqode/providers/huggingface/__init__.py +74 -0
- superqode/providers/huggingface/downloader.py +472 -0
- superqode/providers/huggingface/endpoints.py +442 -0
- superqode/providers/huggingface/hub.py +531 -0
- superqode/providers/huggingface/inference.py +394 -0
- superqode/providers/huggingface/transformers_runner.py +516 -0
- superqode/providers/local/__init__.py +100 -0
- superqode/providers/local/base.py +438 -0
- superqode/providers/local/discovery.py +418 -0
- superqode/providers/local/lmstudio.py +256 -0
- superqode/providers/local/mlx.py +457 -0
- superqode/providers/local/ollama.py +486 -0
- superqode/providers/local/sglang.py +268 -0
- superqode/providers/local/tgi.py +260 -0
- superqode/providers/local/tool_support.py +477 -0
- superqode/providers/local/vllm.py +258 -0
- superqode/providers/manager.py +1338 -0
- superqode/providers/models.py +1016 -0
- superqode/providers/models_dev.py +578 -0
- superqode/providers/openresponses/__init__.py +87 -0
- superqode/providers/openresponses/converters/__init__.py +17 -0
- superqode/providers/openresponses/converters/messages.py +343 -0
- superqode/providers/openresponses/converters/tools.py +268 -0
- superqode/providers/openresponses/schema/__init__.py +56 -0
- superqode/providers/openresponses/schema/models.py +585 -0
- superqode/providers/openresponses/streaming/__init__.py +5 -0
- superqode/providers/openresponses/streaming/parser.py +338 -0
- superqode/providers/openresponses/tools/__init__.py +21 -0
- superqode/providers/openresponses/tools/apply_patch.py +352 -0
- superqode/providers/openresponses/tools/code_interpreter.py +290 -0
- superqode/providers/openresponses/tools/file_search.py +333 -0
- superqode/providers/openresponses/tools/mcp_adapter.py +252 -0
- superqode/providers/registry.py +716 -0
- superqode/providers/usage.py +332 -0
- superqode/pure_mode.py +384 -0
- superqode/qr/__init__.py +23 -0
- superqode/qr/dashboard.py +781 -0
- superqode/qr/generator.py +1018 -0
- superqode/qr/templates.py +135 -0
- superqode/safety/__init__.py +41 -0
- superqode/safety/sandbox.py +413 -0
- superqode/safety/warnings.py +256 -0
- superqode/server/__init__.py +33 -0
- superqode/server/lsp_server.py +775 -0
- superqode/server/web.py +250 -0
- superqode/session/__init__.py +25 -0
- superqode/session/persistence.py +580 -0
- superqode/session/sharing.py +477 -0
- superqode/session.py +475 -0
- superqode/sidebar.py +2991 -0
- superqode/stream_view.py +648 -0
- superqode/styles/__init__.py +3 -0
- superqode/superqe/__init__.py +184 -0
- superqode/superqe/acp_runner.py +1064 -0
- superqode/superqe/constitution/__init__.py +62 -0
- superqode/superqe/constitution/evaluator.py +308 -0
- superqode/superqe/constitution/loader.py +432 -0
- superqode/superqe/constitution/schema.py +250 -0
- superqode/superqe/events.py +591 -0
- superqode/superqe/frameworks/__init__.py +65 -0
- superqode/superqe/frameworks/base.py +234 -0
- superqode/superqe/frameworks/e2e.py +263 -0
- superqode/superqe/frameworks/executor.py +237 -0
- superqode/superqe/frameworks/javascript.py +409 -0
- superqode/superqe/frameworks/python.py +373 -0
- superqode/superqe/frameworks/registry.py +92 -0
- superqode/superqe/mcp_tools/__init__.py +47 -0
- superqode/superqe/mcp_tools/core_tools.py +418 -0
- superqode/superqe/mcp_tools/registry.py +230 -0
- superqode/superqe/mcp_tools/testing_tools.py +167 -0
- superqode/superqe/noise.py +89 -0
- superqode/superqe/orchestrator.py +778 -0
- superqode/superqe/roles.py +609 -0
- superqode/superqe/session.py +713 -0
- superqode/superqe/skills/__init__.py +57 -0
- superqode/superqe/skills/base.py +106 -0
- superqode/superqe/skills/core_skills.py +899 -0
- superqode/superqe/skills/registry.py +90 -0
- superqode/superqe/verifier.py +101 -0
- superqode/superqe_cli.py +76 -0
- superqode/tool_call.py +358 -0
- superqode/tools/__init__.py +93 -0
- superqode/tools/agent_tools.py +496 -0
- superqode/tools/base.py +324 -0
- superqode/tools/batch_tool.py +133 -0
- superqode/tools/diagnostics.py +311 -0
- superqode/tools/edit_tools.py +653 -0
- superqode/tools/enhanced_base.py +515 -0
- superqode/tools/file_tools.py +269 -0
- superqode/tools/file_tracking.py +45 -0
- superqode/tools/lsp_tools.py +610 -0
- superqode/tools/network_tools.py +350 -0
- superqode/tools/permissions.py +400 -0
- superqode/tools/question_tool.py +324 -0
- superqode/tools/search_tools.py +598 -0
- superqode/tools/shell_tools.py +259 -0
- superqode/tools/todo_tools.py +121 -0
- superqode/tools/validation.py +80 -0
- superqode/tools/web_tools.py +639 -0
- superqode/tui.py +1152 -0
- superqode/tui_integration.py +875 -0
- superqode/tui_widgets/__init__.py +27 -0
- superqode/tui_widgets/widgets/__init__.py +18 -0
- superqode/tui_widgets/widgets/progress.py +185 -0
- superqode/tui_widgets/widgets/tool_display.py +188 -0
- superqode/undo_manager.py +574 -0
- superqode/utils/__init__.py +5 -0
- superqode/utils/error_handling.py +323 -0
- superqode/utils/fuzzy.py +257 -0
- superqode/widgets/__init__.py +477 -0
- superqode/widgets/agent_collab.py +390 -0
- superqode/widgets/agent_store.py +936 -0
- superqode/widgets/agent_switcher.py +395 -0
- superqode/widgets/animation_manager.py +284 -0
- superqode/widgets/code_context.py +356 -0
- superqode/widgets/command_palette.py +412 -0
- superqode/widgets/connection_status.py +537 -0
- superqode/widgets/conversation_history.py +470 -0
- superqode/widgets/diff_indicator.py +155 -0
- superqode/widgets/enhanced_status_bar.py +385 -0
- superqode/widgets/enhanced_toast.py +476 -0
- superqode/widgets/file_browser.py +809 -0
- superqode/widgets/file_reference.py +585 -0
- superqode/widgets/issue_timeline.py +340 -0
- superqode/widgets/leader_key.py +264 -0
- superqode/widgets/mode_switcher.py +445 -0
- superqode/widgets/model_picker.py +234 -0
- superqode/widgets/permission_preview.py +1205 -0
- superqode/widgets/prompt.py +358 -0
- superqode/widgets/provider_connect.py +725 -0
- superqode/widgets/pty_shell.py +587 -0
- superqode/widgets/qe_dashboard.py +321 -0
- superqode/widgets/resizable_sidebar.py +377 -0
- superqode/widgets/response_changes.py +218 -0
- superqode/widgets/response_display.py +528 -0
- superqode/widgets/rich_tool_display.py +613 -0
- superqode/widgets/sidebar_panels.py +1180 -0
- superqode/widgets/slash_complete.py +356 -0
- superqode/widgets/split_view.py +612 -0
- superqode/widgets/status_bar.py +273 -0
- superqode/widgets/superqode_display.py +786 -0
- superqode/widgets/thinking_display.py +815 -0
- superqode/widgets/throbber.py +87 -0
- superqode/widgets/toast.py +206 -0
- superqode/widgets/unified_output.py +1073 -0
- superqode/workspace/__init__.py +75 -0
- superqode/workspace/artifacts.py +472 -0
- superqode/workspace/coordinator.py +353 -0
- superqode/workspace/diff_tracker.py +429 -0
- superqode/workspace/git_guard.py +373 -0
- superqode/workspace/git_snapshot.py +526 -0
- superqode/workspace/manager.py +750 -0
- superqode/workspace/snapshot.py +357 -0
- superqode/workspace/watcher.py +535 -0
- superqode/workspace/worktree.py +440 -0
- superqode-0.1.5.dist-info/METADATA +204 -0
- superqode-0.1.5.dist-info/RECORD +288 -0
- superqode-0.1.5.dist-info/WHEEL +5 -0
- superqode-0.1.5.dist-info/entry_points.txt +3 -0
- superqode-0.1.5.dist-info/licenses/LICENSE +648 -0
- superqode-0.1.5.dist-info/top_level.txt +1 -0
superqode/mcp/client.py
ADDED
|
@@ -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}")
|