alita-sdk 0.3.486__py3-none-any.whl → 0.3.515__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.
Potentially problematic release.
This version of alita-sdk might be problematic. Click here for more details.
- alita_sdk/cli/agent_loader.py +27 -6
- alita_sdk/cli/agents.py +10 -1
- alita_sdk/cli/inventory.py +12 -195
- alita_sdk/cli/tools/filesystem.py +95 -9
- alita_sdk/community/inventory/__init__.py +12 -0
- alita_sdk/community/inventory/toolkit.py +9 -5
- alita_sdk/community/inventory/toolkit_utils.py +176 -0
- alita_sdk/configurations/ado.py +144 -0
- alita_sdk/configurations/confluence.py +76 -42
- alita_sdk/configurations/figma.py +76 -0
- alita_sdk/configurations/gitlab.py +2 -0
- alita_sdk/configurations/qtest.py +72 -1
- alita_sdk/configurations/report_portal.py +96 -0
- alita_sdk/configurations/sharepoint.py +148 -0
- alita_sdk/configurations/testio.py +83 -0
- alita_sdk/runtime/clients/artifact.py +2 -2
- alita_sdk/runtime/clients/client.py +64 -40
- alita_sdk/runtime/clients/sandbox_client.py +14 -0
- alita_sdk/runtime/langchain/assistant.py +48 -2
- alita_sdk/runtime/langchain/constants.py +3 -1
- alita_sdk/runtime/langchain/document_loaders/AlitaExcelLoader.py +103 -60
- alita_sdk/runtime/langchain/document_loaders/AlitaJSONLinesLoader.py +77 -0
- alita_sdk/runtime/langchain/document_loaders/AlitaJSONLoader.py +2 -1
- alita_sdk/runtime/langchain/document_loaders/constants.py +12 -7
- alita_sdk/runtime/langchain/langraph_agent.py +10 -10
- alita_sdk/runtime/langchain/utils.py +6 -1
- alita_sdk/runtime/toolkits/artifact.py +14 -5
- alita_sdk/runtime/toolkits/datasource.py +13 -6
- alita_sdk/runtime/toolkits/mcp.py +94 -219
- alita_sdk/runtime/toolkits/planning.py +13 -6
- alita_sdk/runtime/toolkits/tools.py +60 -25
- alita_sdk/runtime/toolkits/vectorstore.py +11 -5
- alita_sdk/runtime/tools/artifact.py +185 -23
- alita_sdk/runtime/tools/function.py +2 -1
- alita_sdk/runtime/tools/llm.py +155 -34
- alita_sdk/runtime/tools/mcp_remote_tool.py +25 -10
- alita_sdk/runtime/tools/mcp_server_tool.py +2 -4
- alita_sdk/runtime/tools/vectorstore_base.py +3 -3
- alita_sdk/runtime/utils/AlitaCallback.py +136 -21
- alita_sdk/runtime/utils/mcp_client.py +492 -0
- alita_sdk/runtime/utils/mcp_oauth.py +125 -8
- alita_sdk/runtime/utils/mcp_sse_client.py +35 -6
- alita_sdk/runtime/utils/mcp_tools_discovery.py +124 -0
- alita_sdk/runtime/utils/toolkit_utils.py +7 -13
- alita_sdk/runtime/utils/utils.py +2 -0
- alita_sdk/tools/__init__.py +15 -0
- alita_sdk/tools/ado/repos/__init__.py +10 -12
- alita_sdk/tools/ado/test_plan/__init__.py +23 -8
- alita_sdk/tools/ado/wiki/__init__.py +24 -8
- alita_sdk/tools/ado/wiki/ado_wrapper.py +21 -7
- alita_sdk/tools/ado/work_item/__init__.py +24 -8
- alita_sdk/tools/advanced_jira_mining/__init__.py +10 -8
- alita_sdk/tools/aws/delta_lake/__init__.py +12 -9
- alita_sdk/tools/aws/delta_lake/tool.py +5 -1
- alita_sdk/tools/azure_ai/search/__init__.py +9 -7
- alita_sdk/tools/base/tool.py +5 -1
- alita_sdk/tools/base_indexer_toolkit.py +26 -1
- alita_sdk/tools/bitbucket/__init__.py +14 -10
- alita_sdk/tools/bitbucket/api_wrapper.py +50 -2
- alita_sdk/tools/browser/__init__.py +5 -4
- alita_sdk/tools/carrier/__init__.py +5 -6
- alita_sdk/tools/chunkers/sematic/json_chunker.py +1 -0
- alita_sdk/tools/chunkers/sematic/markdown_chunker.py +2 -0
- alita_sdk/tools/chunkers/universal_chunker.py +1 -0
- alita_sdk/tools/cloud/aws/__init__.py +9 -7
- alita_sdk/tools/cloud/azure/__init__.py +9 -7
- alita_sdk/tools/cloud/gcp/__init__.py +9 -7
- alita_sdk/tools/cloud/k8s/__init__.py +9 -7
- alita_sdk/tools/code/linter/__init__.py +9 -8
- alita_sdk/tools/code/loaders/codesearcher.py +3 -2
- alita_sdk/tools/code/sonar/__init__.py +9 -7
- alita_sdk/tools/confluence/__init__.py +15 -10
- alita_sdk/tools/confluence/api_wrapper.py +63 -14
- alita_sdk/tools/custom_open_api/__init__.py +11 -5
- alita_sdk/tools/elastic/__init__.py +10 -8
- alita_sdk/tools/elitea_base.py +387 -9
- alita_sdk/tools/figma/__init__.py +8 -7
- alita_sdk/tools/github/__init__.py +12 -14
- alita_sdk/tools/github/github_client.py +68 -2
- alita_sdk/tools/github/tool.py +5 -1
- alita_sdk/tools/gitlab/__init__.py +14 -11
- alita_sdk/tools/gitlab/api_wrapper.py +81 -1
- alita_sdk/tools/gitlab_org/__init__.py +9 -8
- alita_sdk/tools/google/bigquery/__init__.py +12 -12
- alita_sdk/tools/google/bigquery/tool.py +5 -1
- alita_sdk/tools/google_places/__init__.py +9 -8
- alita_sdk/tools/jira/__init__.py +15 -10
- alita_sdk/tools/keycloak/__init__.py +10 -8
- alita_sdk/tools/localgit/__init__.py +8 -3
- alita_sdk/tools/localgit/local_git.py +62 -54
- alita_sdk/tools/localgit/tool.py +5 -1
- alita_sdk/tools/memory/__init__.py +11 -3
- alita_sdk/tools/ocr/__init__.py +10 -8
- alita_sdk/tools/openapi/__init__.py +6 -2
- alita_sdk/tools/pandas/__init__.py +9 -7
- alita_sdk/tools/postman/__init__.py +10 -11
- alita_sdk/tools/pptx/__init__.py +9 -9
- alita_sdk/tools/qtest/__init__.py +9 -8
- alita_sdk/tools/rally/__init__.py +9 -8
- alita_sdk/tools/report_portal/__init__.py +11 -9
- alita_sdk/tools/salesforce/__init__.py +9 -9
- alita_sdk/tools/servicenow/__init__.py +10 -8
- alita_sdk/tools/sharepoint/__init__.py +9 -8
- alita_sdk/tools/sharepoint/api_wrapper.py +2 -2
- alita_sdk/tools/slack/__init__.py +8 -7
- alita_sdk/tools/sql/__init__.py +9 -8
- alita_sdk/tools/testio/__init__.py +9 -8
- alita_sdk/tools/testrail/__init__.py +10 -8
- alita_sdk/tools/utils/__init__.py +9 -4
- alita_sdk/tools/utils/text_operations.py +254 -0
- alita_sdk/tools/vector_adapters/VectorStoreAdapter.py +16 -18
- alita_sdk/tools/xray/__init__.py +10 -8
- alita_sdk/tools/yagmail/__init__.py +8 -3
- alita_sdk/tools/zephyr/__init__.py +8 -7
- alita_sdk/tools/zephyr_enterprise/__init__.py +10 -8
- alita_sdk/tools/zephyr_essential/__init__.py +9 -8
- alita_sdk/tools/zephyr_scale/__init__.py +9 -8
- alita_sdk/tools/zephyr_squad/__init__.py +9 -8
- {alita_sdk-0.3.486.dist-info → alita_sdk-0.3.515.dist-info}/METADATA +1 -1
- {alita_sdk-0.3.486.dist-info → alita_sdk-0.3.515.dist-info}/RECORD +124 -119
- {alita_sdk-0.3.486.dist-info → alita_sdk-0.3.515.dist-info}/WHEEL +0 -0
- {alita_sdk-0.3.486.dist-info → alita_sdk-0.3.515.dist-info}/entry_points.txt +0 -0
- {alita_sdk-0.3.486.dist-info → alita_sdk-0.3.515.dist-info}/licenses/LICENSE +0 -0
- {alita_sdk-0.3.486.dist-info → alita_sdk-0.3.515.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unified MCP Client with auto-detection for SSE and Streamable HTTP transports.
|
|
3
|
+
|
|
4
|
+
This module provides a unified interface for MCP server communication that
|
|
5
|
+
automatically detects and uses the appropriate transport:
|
|
6
|
+
- SSE (Server-Sent Events): Traditional dual-connection model (GET for stream, POST for commands)
|
|
7
|
+
- Streamable HTTP: Newer POST-based model used by servers like GitHub Copilot MCP
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
# Auto-detect transport (recommended)
|
|
11
|
+
client = McpClient(url=url, session_id=session_id, headers=headers)
|
|
12
|
+
|
|
13
|
+
# Force specific transport
|
|
14
|
+
client = McpClient(url=url, session_id=session_id, transport="streamable_http")
|
|
15
|
+
|
|
16
|
+
async with client:
|
|
17
|
+
await client.initialize()
|
|
18
|
+
tools = await client.list_tools()
|
|
19
|
+
result = await client.call_tool("tool_name", {"arg": "value"})
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import json
|
|
24
|
+
import logging
|
|
25
|
+
import uuid
|
|
26
|
+
from typing import Any, Dict, List, Literal, Optional
|
|
27
|
+
|
|
28
|
+
import aiohttp
|
|
29
|
+
|
|
30
|
+
from .mcp_oauth import McpAuthorizationRequired
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
# Transport types
|
|
35
|
+
TransportType = Literal["auto", "sse", "streamable_http"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class McpClient:
|
|
39
|
+
"""
|
|
40
|
+
Unified MCP client that supports both SSE and Streamable HTTP transports.
|
|
41
|
+
|
|
42
|
+
Auto-detects the appropriate transport by trying Streamable HTTP first,
|
|
43
|
+
then falling back to SSE if the server returns 405 Method Not Allowed.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
url: str,
|
|
49
|
+
session_id: Optional[str] = None,
|
|
50
|
+
headers: Optional[Dict[str, str]] = None,
|
|
51
|
+
timeout: int = 300,
|
|
52
|
+
transport: TransportType = "auto"
|
|
53
|
+
):
|
|
54
|
+
"""
|
|
55
|
+
Initialize the unified MCP client.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
url: MCP server URL
|
|
59
|
+
session_id: Session ID for stateful connections (auto-generated if not provided)
|
|
60
|
+
headers: HTTP headers (e.g., Authorization)
|
|
61
|
+
timeout: Request timeout in seconds
|
|
62
|
+
transport: Transport type - "auto", "sse", or "streamable_http"
|
|
63
|
+
"""
|
|
64
|
+
self.url = url
|
|
65
|
+
self.session_id = session_id or str(uuid.uuid4())
|
|
66
|
+
self.headers = headers or {}
|
|
67
|
+
self.timeout = timeout
|
|
68
|
+
self.transport = transport
|
|
69
|
+
|
|
70
|
+
# Will be set during connection
|
|
71
|
+
self._detected_transport: Optional[str] = None
|
|
72
|
+
self._sse_client = None
|
|
73
|
+
self._http_session: Optional[aiohttp.ClientSession] = None
|
|
74
|
+
self._mcp_session_id: Optional[str] = None # Server-provided session ID
|
|
75
|
+
self._initialized = False
|
|
76
|
+
|
|
77
|
+
logger.info(f"[MCP Client] Created for {url} (transport={transport}, session={self.session_id})")
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def server_session_id(self) -> Optional[str]:
|
|
81
|
+
"""Get the server-provided session ID (from mcp-session-id header)."""
|
|
82
|
+
return self._mcp_session_id
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def detected_transport(self) -> Optional[str]:
|
|
86
|
+
"""Get the detected transport type."""
|
|
87
|
+
return self._detected_transport
|
|
88
|
+
|
|
89
|
+
async def __aenter__(self):
|
|
90
|
+
"""Async context manager entry - detect and connect."""
|
|
91
|
+
await self._connect()
|
|
92
|
+
return self
|
|
93
|
+
|
|
94
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
95
|
+
"""Async context manager exit - cleanup."""
|
|
96
|
+
await self.close()
|
|
97
|
+
|
|
98
|
+
async def _connect(self):
|
|
99
|
+
"""Detect transport and establish connection."""
|
|
100
|
+
if self.transport == "sse":
|
|
101
|
+
self._detected_transport = "sse"
|
|
102
|
+
await self._connect_sse()
|
|
103
|
+
elif self.transport == "streamable_http":
|
|
104
|
+
self._detected_transport = "streamable_http"
|
|
105
|
+
await self._connect_streamable_http()
|
|
106
|
+
else: # auto
|
|
107
|
+
await self._auto_detect_and_connect()
|
|
108
|
+
|
|
109
|
+
async def _auto_detect_and_connect(self):
|
|
110
|
+
"""Try Streamable HTTP first, fall back to SSE."""
|
|
111
|
+
# If URL ends with /sse, use SSE transport directly
|
|
112
|
+
if self.url.rstrip('/').endswith('/sse'):
|
|
113
|
+
logger.debug("[MCP Client] URL ends with /sse, using SSE transport")
|
|
114
|
+
await self._connect_sse()
|
|
115
|
+
self._detected_transport = "sse"
|
|
116
|
+
logger.info("[MCP Client] Using SSE transport")
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
logger.debug("[MCP Client] Auto-detecting transport, trying Streamable HTTP first...")
|
|
121
|
+
await self._connect_streamable_http()
|
|
122
|
+
self._detected_transport = "streamable_http"
|
|
123
|
+
logger.info("[MCP Client] Using Streamable HTTP transport")
|
|
124
|
+
except Exception as e:
|
|
125
|
+
error_str = str(e).lower()
|
|
126
|
+
# Check for 405, 404, or indicators that SSE is needed
|
|
127
|
+
if "405" in error_str or "method not allowed" in error_str or "404" in error_str:
|
|
128
|
+
logger.debug(f"[MCP Client] Streamable HTTP not supported ({e}), trying SSE...")
|
|
129
|
+
await self._connect_sse()
|
|
130
|
+
self._detected_transport = "sse"
|
|
131
|
+
logger.info("[MCP Client] Using SSE transport")
|
|
132
|
+
else:
|
|
133
|
+
# Re-raise other errors
|
|
134
|
+
raise
|
|
135
|
+
|
|
136
|
+
async def _connect_streamable_http(self):
|
|
137
|
+
"""Connect using Streamable HTTP transport."""
|
|
138
|
+
self._http_session = aiohttp.ClientSession(
|
|
139
|
+
timeout=aiohttp.ClientTimeout(total=self.timeout)
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
async def _connect_sse(self):
|
|
143
|
+
"""Connect using SSE transport."""
|
|
144
|
+
from .mcp_sse_client import McpSseClient
|
|
145
|
+
|
|
146
|
+
self._sse_client = McpSseClient(
|
|
147
|
+
url=self.url,
|
|
148
|
+
session_id=self.session_id,
|
|
149
|
+
headers=self.headers,
|
|
150
|
+
timeout=self.timeout
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
async def initialize(self) -> Dict[str, Any]:
|
|
154
|
+
"""
|
|
155
|
+
Initialize MCP protocol session.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
Server capabilities and info
|
|
159
|
+
"""
|
|
160
|
+
if self._detected_transport == "streamable_http":
|
|
161
|
+
return await self._initialize_streamable_http()
|
|
162
|
+
else:
|
|
163
|
+
return await self._initialize_sse()
|
|
164
|
+
|
|
165
|
+
async def _initialize_streamable_http(self, retry_without_session: bool = False) -> Dict[str, Any]:
|
|
166
|
+
"""Initialize via Streamable HTTP transport."""
|
|
167
|
+
headers = {
|
|
168
|
+
"Content-Type": "application/json",
|
|
169
|
+
"Accept": "application/json, text/event-stream",
|
|
170
|
+
**self.headers
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
# DON'T send session_id on initialization - per MCP spec, initialization requests
|
|
174
|
+
# must not include a sessionId. The server will provide one in the response.
|
|
175
|
+
# Session ID is only used for subsequent requests after initialization.
|
|
176
|
+
# (The retry_without_session flag is kept for backwards compatibility but
|
|
177
|
+
# is effectively always true for initialization now)
|
|
178
|
+
|
|
179
|
+
# Debug: log headers (mask sensitive data)
|
|
180
|
+
debug_headers = {k: (v[:20] + '...' if k.lower() == 'authorization' and len(v) > 20 else v)
|
|
181
|
+
for k, v in headers.items()}
|
|
182
|
+
logger.debug(f"[MCP Client] Request headers: {debug_headers}")
|
|
183
|
+
|
|
184
|
+
init_request = {
|
|
185
|
+
"jsonrpc": "2.0",
|
|
186
|
+
"id": str(uuid.uuid4()),
|
|
187
|
+
"method": "initialize",
|
|
188
|
+
"params": {
|
|
189
|
+
"protocolVersion": "2024-11-05",
|
|
190
|
+
"capabilities": {
|
|
191
|
+
"roots": {"listChanged": True},
|
|
192
|
+
"sampling": {}
|
|
193
|
+
},
|
|
194
|
+
"clientInfo": {
|
|
195
|
+
"name": "Alita MCP Client",
|
|
196
|
+
"version": "1.0.0"
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
logger.debug(f"[MCP Client] Sending initialize via Streamable HTTP to {self.url}")
|
|
202
|
+
|
|
203
|
+
async with self._http_session.post(self.url, json=init_request, headers=headers) as response:
|
|
204
|
+
if response.status == 401:
|
|
205
|
+
await self._handle_401_response(response)
|
|
206
|
+
|
|
207
|
+
if response.status == 405:
|
|
208
|
+
raise Exception("HTTP 405 Method Not Allowed - server may require SSE transport")
|
|
209
|
+
|
|
210
|
+
# Handle invalid session error - retry without session_id
|
|
211
|
+
if response.status == 400 and not retry_without_session and self.session_id:
|
|
212
|
+
try:
|
|
213
|
+
error_body = await response.text()
|
|
214
|
+
if "invalid session" in error_body.lower():
|
|
215
|
+
logger.warning(f"[MCP Client] Invalid session, retrying without session_id")
|
|
216
|
+
return await self._initialize_streamable_http(retry_without_session=True)
|
|
217
|
+
except Exception:
|
|
218
|
+
pass
|
|
219
|
+
|
|
220
|
+
# Log error response body for debugging
|
|
221
|
+
if response.status >= 400:
|
|
222
|
+
try:
|
|
223
|
+
error_body = await response.text()
|
|
224
|
+
logger.error(f"[MCP Client] HTTP {response.status} error response: {error_body[:1000]}")
|
|
225
|
+
except Exception:
|
|
226
|
+
pass
|
|
227
|
+
|
|
228
|
+
response.raise_for_status()
|
|
229
|
+
|
|
230
|
+
# Get session ID from response headers
|
|
231
|
+
self._mcp_session_id = response.headers.get("mcp-session-id")
|
|
232
|
+
if self._mcp_session_id:
|
|
233
|
+
logger.info(f"[MCP Client] Server provided session_id: {self._mcp_session_id}")
|
|
234
|
+
else:
|
|
235
|
+
logger.debug(f"[MCP Client] No session_id in response headers. Headers: {dict(response.headers)}")
|
|
236
|
+
|
|
237
|
+
# Parse response
|
|
238
|
+
result = await self._parse_response(response)
|
|
239
|
+
logger.debug(f"[MCP Client] Initialize response: {result}")
|
|
240
|
+
|
|
241
|
+
# Send initialized notification
|
|
242
|
+
await self._send_notification("notifications/initialized")
|
|
243
|
+
|
|
244
|
+
self._initialized = True
|
|
245
|
+
return result.get('result', {})
|
|
246
|
+
|
|
247
|
+
async def _initialize_sse(self) -> Dict[str, Any]:
|
|
248
|
+
"""Initialize via SSE transport."""
|
|
249
|
+
result = await self._sse_client.initialize()
|
|
250
|
+
self._initialized = True
|
|
251
|
+
return result
|
|
252
|
+
|
|
253
|
+
async def send_request(
|
|
254
|
+
self,
|
|
255
|
+
method: str,
|
|
256
|
+
params: Optional[Dict[str, Any]] = None,
|
|
257
|
+
request_id: Optional[str] = None
|
|
258
|
+
) -> Dict[str, Any]:
|
|
259
|
+
"""
|
|
260
|
+
Send a JSON-RPC request to the MCP server.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
method: JSON-RPC method name (e.g., "tools/list", "tools/call")
|
|
264
|
+
params: Method parameters
|
|
265
|
+
request_id: Optional request ID (auto-generated if not provided)
|
|
266
|
+
|
|
267
|
+
Returns:
|
|
268
|
+
Parsed JSON-RPC response
|
|
269
|
+
"""
|
|
270
|
+
if self._detected_transport == "streamable_http":
|
|
271
|
+
return await self._send_request_streamable_http(method, params, request_id)
|
|
272
|
+
else:
|
|
273
|
+
return await self._sse_client.send_request(method, params, request_id)
|
|
274
|
+
|
|
275
|
+
async def _send_request_streamable_http(
|
|
276
|
+
self,
|
|
277
|
+
method: str,
|
|
278
|
+
params: Optional[Dict[str, Any]] = None,
|
|
279
|
+
request_id: Optional[str] = None
|
|
280
|
+
) -> Dict[str, Any]:
|
|
281
|
+
"""Send request via Streamable HTTP."""
|
|
282
|
+
if request_id is None:
|
|
283
|
+
request_id = str(uuid.uuid4())
|
|
284
|
+
|
|
285
|
+
headers = {
|
|
286
|
+
"Content-Type": "application/json",
|
|
287
|
+
"Accept": "application/json, text/event-stream",
|
|
288
|
+
**self.headers
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
# Add MCP session ID if we have one
|
|
292
|
+
if self._mcp_session_id:
|
|
293
|
+
headers["mcp-session-id"] = self._mcp_session_id
|
|
294
|
+
|
|
295
|
+
request = {
|
|
296
|
+
"jsonrpc": "2.0",
|
|
297
|
+
"id": request_id,
|
|
298
|
+
"method": method,
|
|
299
|
+
"params": params or {}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
logger.debug(f"[MCP Client] Sending request: {method} (id={request_id})")
|
|
303
|
+
|
|
304
|
+
async with self._http_session.post(self.url, json=request, headers=headers) as response:
|
|
305
|
+
if response.status == 401:
|
|
306
|
+
await self._handle_401_response(response)
|
|
307
|
+
|
|
308
|
+
response.raise_for_status()
|
|
309
|
+
|
|
310
|
+
result = await self._parse_response(response)
|
|
311
|
+
|
|
312
|
+
# Check for JSON-RPC error
|
|
313
|
+
if 'error' in result:
|
|
314
|
+
error = result['error']
|
|
315
|
+
raise Exception(f"MCP Error: {error.get('message', str(error))}")
|
|
316
|
+
|
|
317
|
+
return result
|
|
318
|
+
|
|
319
|
+
async def _send_notification(self, method: str, params: Optional[Dict[str, Any]] = None):
|
|
320
|
+
"""Send a JSON-RPC notification (no response expected)."""
|
|
321
|
+
if self._detected_transport == "streamable_http":
|
|
322
|
+
headers = {
|
|
323
|
+
"Content-Type": "application/json",
|
|
324
|
+
**self.headers
|
|
325
|
+
}
|
|
326
|
+
if self._mcp_session_id:
|
|
327
|
+
headers["mcp-session-id"] = self._mcp_session_id
|
|
328
|
+
|
|
329
|
+
notification = {
|
|
330
|
+
"jsonrpc": "2.0",
|
|
331
|
+
"method": method
|
|
332
|
+
}
|
|
333
|
+
if params:
|
|
334
|
+
notification["params"] = params
|
|
335
|
+
|
|
336
|
+
async with self._http_session.post(self.url, json=notification, headers=headers) as response:
|
|
337
|
+
pass # Notifications don't expect a response
|
|
338
|
+
|
|
339
|
+
async def _parse_response(self, response: aiohttp.ClientResponse) -> Dict[str, Any]:
|
|
340
|
+
"""Parse response, handling both JSON and SSE formats."""
|
|
341
|
+
content_type = response.headers.get("content-type", "")
|
|
342
|
+
text = await response.text()
|
|
343
|
+
|
|
344
|
+
if "text/event-stream" in content_type:
|
|
345
|
+
return self._parse_sse_text(text)
|
|
346
|
+
else:
|
|
347
|
+
return json.loads(text) if text else {}
|
|
348
|
+
|
|
349
|
+
def _parse_sse_text(self, text: str) -> Dict[str, Any]:
|
|
350
|
+
"""Parse SSE formatted response to extract JSON data."""
|
|
351
|
+
for line in text.split('\n'):
|
|
352
|
+
if line.startswith('data:'):
|
|
353
|
+
data = line[5:].strip()
|
|
354
|
+
if data:
|
|
355
|
+
return json.loads(data)
|
|
356
|
+
return {}
|
|
357
|
+
|
|
358
|
+
async def _handle_401_response(self, response: aiohttp.ClientResponse):
|
|
359
|
+
"""Handle 401 Unauthorized response with OAuth flow."""
|
|
360
|
+
from .mcp_oauth import (
|
|
361
|
+
canonical_resource,
|
|
362
|
+
extract_resource_metadata_url,
|
|
363
|
+
extract_authorization_uri,
|
|
364
|
+
fetch_resource_metadata_async,
|
|
365
|
+
infer_authorization_servers_from_realm,
|
|
366
|
+
fetch_oauth_authorization_server_metadata
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
auth_header = response.headers.get('WWW-Authenticate', '')
|
|
370
|
+
resource_metadata_url = extract_resource_metadata_url(auth_header, self.url)
|
|
371
|
+
|
|
372
|
+
# First, try authorization_uri from WWW-Authenticate header (preferred)
|
|
373
|
+
authorization_uri = extract_authorization_uri(auth_header)
|
|
374
|
+
|
|
375
|
+
metadata = None
|
|
376
|
+
if authorization_uri:
|
|
377
|
+
# Fetch OAuth metadata directly from authorization_uri
|
|
378
|
+
auth_server_metadata = fetch_oauth_authorization_server_metadata(authorization_uri, timeout=30)
|
|
379
|
+
if auth_server_metadata:
|
|
380
|
+
# Extract base authorization server URL from the issuer or the well-known URL
|
|
381
|
+
base_auth_server = auth_server_metadata.get('issuer')
|
|
382
|
+
if not base_auth_server and '/.well-known/' in authorization_uri:
|
|
383
|
+
base_auth_server = authorization_uri.split('/.well-known/')[0]
|
|
384
|
+
|
|
385
|
+
metadata = {
|
|
386
|
+
'authorization_servers': [base_auth_server] if base_auth_server else [authorization_uri],
|
|
387
|
+
'oauth_authorization_server': auth_server_metadata
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
# Fall back to resource_metadata if authorization_uri didn't work
|
|
391
|
+
if not metadata:
|
|
392
|
+
if resource_metadata_url:
|
|
393
|
+
metadata = await fetch_resource_metadata_async(
|
|
394
|
+
resource_metadata_url,
|
|
395
|
+
session=self._http_session,
|
|
396
|
+
timeout=30
|
|
397
|
+
)
|
|
398
|
+
# If we got resource_metadata, also fetch oauth_authorization_server
|
|
399
|
+
if metadata and metadata.get('authorization_servers'):
|
|
400
|
+
auth_server_metadata = fetch_oauth_authorization_server_metadata(
|
|
401
|
+
metadata['authorization_servers'][0], timeout=30
|
|
402
|
+
)
|
|
403
|
+
if auth_server_metadata:
|
|
404
|
+
metadata['oauth_authorization_server'] = auth_server_metadata
|
|
405
|
+
|
|
406
|
+
# Infer authorization servers if not in metadata
|
|
407
|
+
if not metadata or not metadata.get('authorization_servers'):
|
|
408
|
+
inferred_servers = infer_authorization_servers_from_realm(auth_header, self.url)
|
|
409
|
+
if inferred_servers:
|
|
410
|
+
if not metadata:
|
|
411
|
+
metadata = {}
|
|
412
|
+
metadata['authorization_servers'] = inferred_servers
|
|
413
|
+
|
|
414
|
+
# Fetch OAuth metadata
|
|
415
|
+
auth_server_metadata = fetch_oauth_authorization_server_metadata(inferred_servers[0], timeout=30)
|
|
416
|
+
if auth_server_metadata:
|
|
417
|
+
metadata['oauth_authorization_server'] = auth_server_metadata
|
|
418
|
+
|
|
419
|
+
raise McpAuthorizationRequired(
|
|
420
|
+
message=f"MCP server {self.url} requires OAuth authorization",
|
|
421
|
+
server_url=canonical_resource(self.url),
|
|
422
|
+
resource_metadata_url=resource_metadata_url,
|
|
423
|
+
www_authenticate=auth_header,
|
|
424
|
+
resource_metadata=metadata,
|
|
425
|
+
status=401,
|
|
426
|
+
tool_name=self.url,
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
async def list_tools(self) -> List[Dict[str, Any]]:
|
|
430
|
+
"""
|
|
431
|
+
Get list of available tools from the MCP server.
|
|
432
|
+
|
|
433
|
+
Returns:
|
|
434
|
+
List of tool definitions
|
|
435
|
+
"""
|
|
436
|
+
response = await self.send_request("tools/list")
|
|
437
|
+
result = response.get('result', {})
|
|
438
|
+
tools = result.get('tools', [])
|
|
439
|
+
logger.info(f"[MCP Client] Discovered {len(tools)} tools")
|
|
440
|
+
return tools
|
|
441
|
+
|
|
442
|
+
async def list_prompts(self) -> List[Dict[str, Any]]:
|
|
443
|
+
"""
|
|
444
|
+
Get list of available prompts from the MCP server.
|
|
445
|
+
|
|
446
|
+
Returns:
|
|
447
|
+
List of prompt definitions
|
|
448
|
+
"""
|
|
449
|
+
response = await self.send_request("prompts/list")
|
|
450
|
+
result = response.get('result', {})
|
|
451
|
+
prompts = result.get('prompts', [])
|
|
452
|
+
logger.debug(f"[MCP Client] Discovered {len(prompts)} prompts")
|
|
453
|
+
return prompts
|
|
454
|
+
|
|
455
|
+
async def call_tool(self, tool_name: str, arguments: Dict[str, Any]) -> Any:
|
|
456
|
+
"""
|
|
457
|
+
Execute a tool on the MCP server.
|
|
458
|
+
|
|
459
|
+
Args:
|
|
460
|
+
tool_name: Name of the tool to call
|
|
461
|
+
arguments: Tool arguments
|
|
462
|
+
|
|
463
|
+
Returns:
|
|
464
|
+
Tool execution result
|
|
465
|
+
"""
|
|
466
|
+
response = await self.send_request(
|
|
467
|
+
"tools/call",
|
|
468
|
+
params={
|
|
469
|
+
"name": tool_name,
|
|
470
|
+
"arguments": arguments
|
|
471
|
+
}
|
|
472
|
+
)
|
|
473
|
+
return response.get('result', {})
|
|
474
|
+
|
|
475
|
+
async def close(self):
|
|
476
|
+
"""Close the client and cleanup resources."""
|
|
477
|
+
logger.info(f"[MCP Client] Closing connection...")
|
|
478
|
+
|
|
479
|
+
if self._sse_client:
|
|
480
|
+
await self._sse_client.close()
|
|
481
|
+
self._sse_client = None
|
|
482
|
+
|
|
483
|
+
if self._http_session and not self._http_session.closed:
|
|
484
|
+
await self._http_session.close()
|
|
485
|
+
self._http_session = None
|
|
486
|
+
|
|
487
|
+
logger.info(f"[MCP Client] Connection closed")
|
|
488
|
+
|
|
489
|
+
@property
|
|
490
|
+
def detected_transport(self) -> Optional[str]:
|
|
491
|
+
"""Return the detected/selected transport type."""
|
|
492
|
+
return self._detected_transport
|
|
@@ -43,6 +43,23 @@ class McpAuthorizationRequired(ToolException):
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
|
|
46
|
+
def extract_authorization_uri(www_authenticate: Optional[str]) -> Optional[str]:
|
|
47
|
+
"""
|
|
48
|
+
Extract authorization_uri from WWW-Authenticate header.
|
|
49
|
+
This points directly to the OAuth authorization server metadata URL.
|
|
50
|
+
Should be used before falling back to resource_metadata.
|
|
51
|
+
"""
|
|
52
|
+
if not www_authenticate:
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
# Look for authorization_uri="<url>" in the header
|
|
56
|
+
match = re.search(r'authorization_uri\s*=\s*\"?([^\", ]+)\"?', www_authenticate)
|
|
57
|
+
if match:
|
|
58
|
+
return match.group(1)
|
|
59
|
+
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
46
63
|
def extract_resource_metadata_url(www_authenticate: Optional[str], server_url: Optional[str] = None) -> Optional[str]:
|
|
47
64
|
"""
|
|
48
65
|
Pull the resource_metadata URL from a WWW-Authenticate header if present.
|
|
@@ -62,15 +79,33 @@ def extract_resource_metadata_url(www_authenticate: Optional[str], server_url: O
|
|
|
62
79
|
# or using well-known OAuth discovery endpoints directly
|
|
63
80
|
return None
|
|
64
81
|
|
|
65
|
-
|
|
66
|
-
def fetch_oauth_authorization_server_metadata(base_url: str, timeout: int = 10) -> Optional[Dict[str, Any]]:
|
|
82
|
+
def fetch_oauth_authorization_server_metadata(url: str, timeout: int = 10) -> Optional[Dict[str, Any]]:
|
|
67
83
|
"""
|
|
68
84
|
Fetch OAuth authorization server metadata from well-known endpoints.
|
|
69
|
-
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
url: Either a full well-known URL (e.g., https://api.figma.com/.well-known/oauth-authorization-server)
|
|
88
|
+
or a base URL (e.g., https://api.figma.com) where we'll try discovery endpoints.
|
|
89
|
+
timeout: Request timeout in seconds.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
OAuth authorization server metadata dict, or None if not found.
|
|
70
93
|
"""
|
|
94
|
+
# If the URL is already a .well-known endpoint, try it directly first
|
|
95
|
+
if '/.well-known/' in url:
|
|
96
|
+
try:
|
|
97
|
+
resp = requests.get(url, timeout=timeout)
|
|
98
|
+
if resp.status_code == 200:
|
|
99
|
+
return resp.json()
|
|
100
|
+
except Exception as exc:
|
|
101
|
+
logger.debug(f"Failed to fetch OAuth metadata from {url}: {exc}")
|
|
102
|
+
# If direct fetch failed, don't try other endpoints
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
# Otherwise, try standard discovery endpoints
|
|
71
106
|
discovery_endpoints = [
|
|
72
|
-
f"{
|
|
73
|
-
f"{
|
|
107
|
+
f"{url}/.well-known/oauth-authorization-server",
|
|
108
|
+
f"{url}/.well-known/openid-configuration",
|
|
74
109
|
]
|
|
75
110
|
|
|
76
111
|
for endpoint in discovery_endpoints:
|
|
@@ -168,7 +203,7 @@ def exchange_oauth_token(
|
|
|
168
203
|
token_endpoint: str,
|
|
169
204
|
code: str,
|
|
170
205
|
redirect_uri: str,
|
|
171
|
-
client_id: str,
|
|
206
|
+
client_id: Optional[str] = None,
|
|
172
207
|
client_secret: Optional[str] = None,
|
|
173
208
|
code_verifier: Optional[str] = None,
|
|
174
209
|
scope: Optional[str] = None,
|
|
@@ -184,7 +219,7 @@ def exchange_oauth_token(
|
|
|
184
219
|
token_endpoint: OAuth token endpoint URL
|
|
185
220
|
code: Authorization code from OAuth provider
|
|
186
221
|
redirect_uri: Redirect URI used in authorization request
|
|
187
|
-
client_id: OAuth client ID
|
|
222
|
+
client_id: OAuth client ID (optional for DCR/public clients)
|
|
188
223
|
client_secret: OAuth client secret (optional for public clients)
|
|
189
224
|
code_verifier: PKCE code verifier (optional)
|
|
190
225
|
scope: OAuth scope (optional)
|
|
@@ -196,15 +231,22 @@ def exchange_oauth_token(
|
|
|
196
231
|
Raises:
|
|
197
232
|
requests.RequestException: If the HTTP request fails
|
|
198
233
|
ValueError: If the token exchange fails
|
|
234
|
+
|
|
235
|
+
Note:
|
|
236
|
+
client_id may be optional for:
|
|
237
|
+
- Dynamic Client Registration (DCR): client_id may be in the code
|
|
238
|
+
- OIDC public clients: some providers don't require it
|
|
239
|
+
- Some MCP servers handle auth differently
|
|
199
240
|
"""
|
|
200
241
|
# Build the token request body
|
|
201
242
|
token_body = {
|
|
202
243
|
"grant_type": "authorization_code",
|
|
203
244
|
"code": code,
|
|
204
245
|
"redirect_uri": redirect_uri,
|
|
205
|
-
"client_id": client_id,
|
|
206
246
|
}
|
|
207
247
|
|
|
248
|
+
if client_id:
|
|
249
|
+
token_body["client_id"] = client_id
|
|
208
250
|
if client_secret:
|
|
209
251
|
token_body["client_secret"] = client_secret
|
|
210
252
|
if code_verifier:
|
|
@@ -242,3 +284,78 @@ def exchange_oauth_token(
|
|
|
242
284
|
logger.error(f"MCP OAuth: token exchange failed - {response.status_code}: {error_msg}")
|
|
243
285
|
raise ValueError(f"Token exchange failed: {error_msg}")
|
|
244
286
|
|
|
287
|
+
|
|
288
|
+
def refresh_oauth_token(
|
|
289
|
+
token_endpoint: str,
|
|
290
|
+
refresh_token: str,
|
|
291
|
+
client_id: Optional[str] = None,
|
|
292
|
+
client_secret: Optional[str] = None,
|
|
293
|
+
scope: Optional[str] = None,
|
|
294
|
+
timeout: int = 30,
|
|
295
|
+
) -> Dict[str, Any]:
|
|
296
|
+
"""
|
|
297
|
+
Refresh an OAuth access token using a refresh token.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
token_endpoint: OAuth token endpoint URL
|
|
301
|
+
refresh_token: Refresh token from previous authorization
|
|
302
|
+
client_id: OAuth client ID (optional for DCR/public clients)
|
|
303
|
+
client_secret: OAuth client secret (optional for public clients)
|
|
304
|
+
scope: OAuth scope (optional)
|
|
305
|
+
timeout: Request timeout in seconds
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
Token response from OAuth provider containing access_token, etc.
|
|
309
|
+
May also include a new refresh_token depending on the provider.
|
|
310
|
+
|
|
311
|
+
Raises:
|
|
312
|
+
requests.RequestException: If the HTTP request fails
|
|
313
|
+
ValueError: If the token refresh fails
|
|
314
|
+
|
|
315
|
+
Note:
|
|
316
|
+
client_id may be optional for:
|
|
317
|
+
- Dynamic Client Registration (DCR): client_id embedded in refresh_token
|
|
318
|
+
- OIDC public clients: some providers don't require it
|
|
319
|
+
- Some MCP servers handle auth differently
|
|
320
|
+
"""
|
|
321
|
+
token_body = {
|
|
322
|
+
"grant_type": "refresh_token",
|
|
323
|
+
"refresh_token": refresh_token,
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if client_id:
|
|
327
|
+
token_body["client_id"] = client_id
|
|
328
|
+
if client_secret:
|
|
329
|
+
token_body["client_secret"] = client_secret
|
|
330
|
+
if scope:
|
|
331
|
+
token_body["scope"] = scope
|
|
332
|
+
|
|
333
|
+
logger.info(f"MCP OAuth: refreshing token at {token_endpoint}")
|
|
334
|
+
|
|
335
|
+
response = requests.post(
|
|
336
|
+
token_endpoint,
|
|
337
|
+
data=token_body,
|
|
338
|
+
headers={
|
|
339
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
340
|
+
"Accept": "application/json",
|
|
341
|
+
},
|
|
342
|
+
timeout=timeout
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
# Try to parse as JSON
|
|
346
|
+
try:
|
|
347
|
+
token_data = response.json()
|
|
348
|
+
except Exception:
|
|
349
|
+
# Some providers return URL-encoded response
|
|
350
|
+
from urllib.parse import parse_qs
|
|
351
|
+
token_data = {k: v[0] if len(v) == 1 else v
|
|
352
|
+
for k, v in parse_qs(response.text).items()}
|
|
353
|
+
|
|
354
|
+
if response.ok:
|
|
355
|
+
logger.info("MCP OAuth: token refresh successful")
|
|
356
|
+
return token_data
|
|
357
|
+
else:
|
|
358
|
+
error_msg = token_data.get("error_description") or token_data.get("error") or response.text
|
|
359
|
+
logger.error(f"MCP OAuth: token refresh failed - {response.status_code}: {error_msg}")
|
|
360
|
+
raise ValueError(f"Token refresh failed: {error_msg}")
|
|
361
|
+
|