alita-sdk 0.3.448__py3-none-any.whl → 0.3.450__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.
@@ -7,6 +7,7 @@ Following MCP specification: https://modelcontextprotocol.io/specification/2025-
7
7
  import logging
8
8
  import re
9
9
  import requests
10
+ import asyncio
10
11
  from typing import List, Optional, Any, Dict, Literal, ClassVar, Union
11
12
 
12
13
  from langchain_core.tools import BaseToolkit, BaseTool
@@ -17,6 +18,7 @@ from ..tools.mcp_remote_tool import McpRemoteTool
17
18
  from ..tools.mcp_inspect_tool import McpInspectTool
18
19
  from ...tools.utils import TOOLKIT_SPLITTER, clean_string
19
20
  from ..models.mcp_models import McpConnectionConfig
21
+ from ..utils.mcp_sse_client import McpSseClient
20
22
  from ..utils.mcp_oauth import (
21
23
  McpAuthorizationRequired,
22
24
  canonical_resource,
@@ -459,42 +461,79 @@ class McpToolkit(BaseToolkit):
459
461
  timeout: int
460
462
  ) -> List[Dict[str, Any]]:
461
463
  """
462
- Synchronously discover tools and prompts from MCP server using HTTP requests.
464
+ Discover tools and prompts from MCP server using SSE client.
463
465
  Returns list of tool/prompt dictionaries with name, description, and inputSchema.
464
466
  Prompts are converted to tools that can be invoked.
465
467
  """
466
- all_tools = []
467
-
468
- # Use session_id from UI (passed via connection_config)
469
- # For SSE-based MCP servers, frontend generates a UUID and sends it with the OAuth token
470
468
  session_id = connection_config.session_id
471
469
 
472
470
  if not session_id:
473
471
  logger.warning(f"[MCP Session] No session_id provided for '{toolkit_name}' - server may require it")
474
472
  logger.warning(f"[MCP Session] Frontend should generate a UUID and include it with mcp_tokens")
475
473
 
476
- # Discover regular tools
477
- tools_data = cls._discover_mcp_endpoint(
478
- endpoint="tools/list",
479
- toolkit_name=toolkit_name,
480
- connection_config=connection_config,
481
- timeout=timeout,
482
- session_id=session_id
474
+ # Run async discovery in sync context
475
+ try:
476
+ all_tools = asyncio.run(
477
+ cls._discover_tools_async(
478
+ toolkit_name=toolkit_name,
479
+ connection_config=connection_config,
480
+ timeout=timeout
481
+ )
482
+ )
483
+ return all_tools, session_id
484
+ except Exception as e:
485
+ logger.error(f"[MCP SSE] Discovery failed for '{toolkit_name}': {e}")
486
+ raise
487
+
488
+ @classmethod
489
+ async def _discover_tools_async(
490
+ cls,
491
+ toolkit_name: str,
492
+ connection_config: McpConnectionConfig,
493
+ timeout: int
494
+ ) -> List[Dict[str, Any]]:
495
+ """
496
+ Async implementation of tool discovery using SSE client.
497
+ """
498
+ all_tools = []
499
+ session_id = connection_config.session_id
500
+
501
+ # Generate temporary session_id if not provided (for OAuth flow)
502
+ # The real session_id should come from frontend after OAuth completes
503
+ if not session_id:
504
+ import uuid
505
+ session_id = str(uuid.uuid4())
506
+ logger.info(f"[MCP SSE] Generated temporary session_id for OAuth: {session_id}")
507
+
508
+ logger.info(f"[MCP SSE] Discovering from {connection_config.url} with session {session_id}")
509
+
510
+ # Prepare headers
511
+ headers = {}
512
+ if connection_config.headers:
513
+ headers.update(connection_config.headers)
514
+
515
+ # Create SSE client
516
+ client = McpSseClient(
517
+ url=connection_config.url,
518
+ session_id=session_id,
519
+ headers=headers,
520
+ timeout=timeout
483
521
  )
484
- all_tools.extend(tools_data)
485
- logger.info(f"Discovered {len(tools_data)} tools from MCP toolkit '{toolkit_name}'")
486
522
 
487
- # Discover prompts and convert them to tools
523
+ # Initialize MCP session
524
+ await client.initialize()
525
+ logger.info(f"[MCP SSE] Session initialized for '{toolkit_name}'")
526
+
527
+ # Discover tools
528
+ tools = await client.list_tools()
529
+ all_tools.extend(tools)
530
+ logger.info(f"[MCP SSE] Discovered {len(tools)} tools from '{toolkit_name}'")
531
+
532
+ # Discover prompts
488
533
  try:
489
- prompts_data = cls._discover_mcp_endpoint(
490
- endpoint="prompts/list",
491
- toolkit_name=toolkit_name,
492
- connection_config=connection_config,
493
- timeout=timeout,
494
- session_id=session_id
495
- )
534
+ prompts = await client.list_prompts()
496
535
  # Convert prompts to tool format
497
- for prompt in prompts_data:
536
+ for prompt in prompts:
498
537
  prompt_tool = {
499
538
  "name": f"prompt_{prompt.get('name', 'unnamed')}",
500
539
  "description": prompt.get('description', f"Execute prompt: {prompt.get('name')}"),
@@ -519,152 +558,12 @@ class McpToolkit(BaseToolkit):
519
558
  "_mcp_prompt_name": prompt.get('name')
520
559
  }
521
560
  all_tools.append(prompt_tool)
522
- logger.info(f"Discovered {len(prompts_data)} prompts from MCP toolkit '{toolkit_name}'")
523
- except Exception as e:
524
- logger.warning(f"Failed to discover prompts from MCP toolkit '{toolkit_name}': {e}")
525
-
526
- logger.info(f"Total discovered {len(all_tools)} tools+prompts from MCP toolkit '{toolkit_name}'")
527
- return all_tools, session_id
528
-
529
- @classmethod
530
- def _discover_mcp_endpoint(
531
- cls,
532
- endpoint: str,
533
- toolkit_name: str,
534
- connection_config: McpConnectionConfig,
535
- timeout: int,
536
- session_id: Optional[str] = None
537
- ) -> List[Dict[str, Any]]:
538
- """
539
- Discover items from a specific MCP endpoint (tools/list or prompts/list).
540
- Returns list of dictionaries.
541
- """
542
- import time
543
-
544
- # MCP protocol request
545
- mcp_request = {
546
- "jsonrpc": "2.0",
547
- "id": f"discover_{endpoint.replace('/', '_')}_{int(time.time())}",
548
- "method": endpoint,
549
- "params": {}
550
- }
551
-
552
- headers = {
553
- "Content-Type": "application/json",
554
- "Accept": "application/json, text/event-stream"
555
- }
556
- if connection_config.headers:
557
- headers.update(connection_config.headers)
558
-
559
- # Add sessionId to URL if provided (for stateful SSE servers)
560
- url = connection_config.url
561
- if session_id:
562
- separator = '&' if '?' in url else '?'
563
- url = f"{url}{separator}sessionId={session_id}"
564
- logger.info(f"[MCP Session] Using session {session_id} for {endpoint} request")
565
- else:
566
- logger.debug(f"[MCP Session] No session ID available for {endpoint} request - server may not require sessions")
567
-
568
- try:
569
- logger.debug(f"Sending MCP {endpoint} request to {url}")
570
- response = requests.post(
571
- url,
572
- json=mcp_request,
573
- headers=headers,
574
- timeout=timeout
575
- )
576
-
577
- auth_header = response.headers.get('WWW-Authenticate', '')
578
- if response.status_code == 401:
579
- resource_metadata_url = extract_resource_metadata_url(auth_header, connection_config.url)
580
- metadata = fetch_resource_metadata(resource_metadata_url, timeout=timeout) if resource_metadata_url else None
581
-
582
- # If we couldn't get metadata from the resource_metadata endpoint,
583
- # infer authorization servers from the WWW-Authenticate header and server URL
584
- if not metadata or not metadata.get('authorization_servers'):
585
- inferred_servers = infer_authorization_servers_from_realm(auth_header, connection_config.url)
586
- if inferred_servers:
587
- if not metadata:
588
- metadata = {}
589
- metadata['authorization_servers'] = inferred_servers
590
- logger.info(f"Inferred authorization servers for {connection_config.url}: {inferred_servers}")
591
-
592
- # Fetch OAuth authorization server metadata from the inferred server
593
- # This avoids CORS issues in the frontend
594
- from alita_sdk.runtime.utils.mcp_oauth import fetch_oauth_authorization_server_metadata
595
- auth_server_metadata = fetch_oauth_authorization_server_metadata(inferred_servers[0], timeout=timeout)
596
- if auth_server_metadata:
597
- metadata['oauth_authorization_server'] = auth_server_metadata
598
- logger.info(f"Fetched OAuth metadata for {inferred_servers[0]}")
599
-
600
- raise McpAuthorizationRequired(
601
- message=f"MCP server {connection_config.url} requires OAuth authorization",
602
- server_url=canonical_resource(connection_config.url),
603
- resource_metadata_url=resource_metadata_url,
604
- www_authenticate=auth_header,
605
- resource_metadata=metadata,
606
- status=response.status_code,
607
- tool_name=toolkit_name,
608
- )
609
-
610
- if response.status_code != 200:
611
- logger.error(f"MCP server returned non-200 status: {response.status_code}")
612
- raise Exception(f"HTTP {response.status_code}: {response.text}")
613
-
614
- # Check content type and parse accordingly
615
- content_type = response.headers.get('Content-Type', '')
616
-
617
- if 'text/event-stream' in content_type:
618
- # Parse SSE (Server-Sent Events) format
619
- data = cls._parse_sse_response(response.text)
620
- elif 'application/json' in content_type:
621
- # Parse regular JSON
622
- try:
623
- data = response.json()
624
- except ValueError as json_err:
625
- raise Exception(f"Invalid JSON response: {json_err}. Response text: {response.text[:200]}")
626
- else:
627
- raise Exception(f"Unexpected Content-Type: {content_type}. Response: {response.text[:200]}")
628
-
629
- if "error" in data:
630
- raise Exception(f"MCP Error: {data['error']}")
631
-
632
- # Parse MCP response - different endpoints return different keys
633
- result = data.get("result", {})
634
- if endpoint == "tools/list":
635
- return result.get("tools", [])
636
- elif endpoint == "prompts/list":
637
- return result.get("prompts", [])
638
- else:
639
- return result.get("items", [])
640
-
561
+ logger.info(f"[MCP SSE] Discovered {len(prompts)} prompts from '{toolkit_name}'")
641
562
  except Exception as e:
642
- logger.error(f"Failed to discover from {endpoint} on MCP toolkit '{toolkit_name}': {e}")
643
- raise
644
-
645
- @staticmethod
646
- def _parse_sse_response(sse_text: str) -> Dict[str, Any]:
647
- """
648
- Parse Server-Sent Events (SSE) format response.
649
- SSE format: event: message\ndata: {json}\n\n
650
- """
651
- import json
563
+ logger.warning(f"[MCP SSE] Failed to discover prompts: {e}")
652
564
 
653
- lines = sse_text.strip().split('\n')
654
- data_line = None
655
-
656
- for line in lines:
657
- if line.startswith('data:'):
658
- data_line = line[5:].strip() # Remove 'data:' prefix
659
- break
660
-
661
- if not data_line:
662
- raise Exception(f"No data found in SSE response: {sse_text[:200]}")
663
-
664
- try:
665
- return json.loads(data_line)
666
- except json.JSONDecodeError as e:
667
- raise Exception(f"Failed to parse SSE data as JSON: {e}. Data: {data_line[:200]}")
565
+ logger.info(f"[MCP SSE] Total discovered {len(all_tools)} items from '{toolkit_name}'")
566
+ return all_tools
668
567
 
669
568
  @classmethod
670
569
  def _create_tool_from_dict(
@@ -20,6 +20,7 @@ from ..utils.mcp_oauth import (
20
20
  fetch_resource_metadata_async,
21
21
  infer_authorization_servers_from_realm,
22
22
  )
23
+ from ..utils.mcp_sse_client import McpSseClient
23
24
 
24
25
  logger = logging.getLogger(__name__)
25
26
 
@@ -83,172 +84,68 @@ class McpRemoteTool(McpServerTool):
83
84
  return asyncio.run(self._execute_remote_tool(kwargs))
84
85
 
85
86
  async def _execute_remote_tool(self, kwargs: Dict[str, Any]) -> str:
86
- """Execute the actual remote MCP tool call."""
87
- import aiohttp
87
+ """Execute the actual remote MCP tool call using SSE client."""
88
88
  from ...tools.utils import TOOLKIT_SPLITTER
89
89
 
90
+ # Check for session_id requirement
91
+ if not self.session_id:
92
+ logger.error(f"[MCP Session] Missing session_id for tool '{self.name}'")
93
+ raise Exception("sessionId required. Frontend must generate UUID and send with mcp_tokens.")
94
+
90
95
  # Use the original tool name from discovery for MCP server invocation
91
- # The MCP server doesn't know about our optimized names
92
96
  tool_name_for_server = self.original_tool_name
93
-
94
- # Fallback: extract from optimized name if original not stored (backwards compatibility)
95
97
  if not tool_name_for_server:
96
98
  tool_name_for_server = self.name.rsplit(TOOLKIT_SPLITTER, 1)[-1] if TOOLKIT_SPLITTER in self.name else self.name
97
- logger.warning(f"original_tool_name not set for '{self.name}', using extracted name: {tool_name_for_server}")
99
+ logger.warning(f"original_tool_name not set for '{self.name}', using extracted: {tool_name_for_server}")
98
100
 
99
- # Build the MCP request based on whether this is a prompt or tool
100
- if self.is_prompt:
101
- # For prompts, use prompts/get endpoint
102
- mcp_request = {
103
- "jsonrpc": "2.0",
104
- "id": f"prompt_get_{int(time.time())}_{uuid.uuid4().hex[:8]}",
105
- "method": "prompts/get",
106
- "params": {
107
- "name": self.prompt_name or tool_name_for_server.replace("prompt_", ""),
108
- "arguments": kwargs.get("arguments", kwargs)
109
- }
110
- }
111
- else:
112
- # For regular tools, use tools/call endpoint
113
- mcp_request = {
114
- "jsonrpc": "2.0",
115
- "id": f"tool_call_{int(time.time())}_{uuid.uuid4().hex[:8]}",
116
- "method": "tools/call",
117
- "params": {
118
- "name": tool_name_for_server,
119
- "arguments": kwargs
120
- }
121
- }
122
-
123
- # Set up headers
124
- headers = {
125
- "Content-Type": "application/json",
126
- "Accept": "application/json, text/event-stream"
127
- }
128
- if self.server_headers:
129
- headers.update(self.server_headers)
130
-
131
- # Add sessionId to URL if this is a stateful SSE server
132
- url = self.server_url
133
- if self.session_id:
134
- separator = '&' if '?' in url else '?'
135
- url = f"{url}{separator}sessionId={self.session_id}"
136
- logger.debug(f"Using session URL: {url}")
137
-
138
- # Execute the HTTP request
139
- timeout = aiohttp.ClientTimeout(total=self.tool_timeout_sec)
140
- async with aiohttp.ClientSession(timeout=timeout) as session:
141
- try:
142
- logger.debug(f"Calling remote MCP tool '{tool_name_for_server}' (optimized name: '{self.name}') at {url}")
143
- logger.debug(f"Request: {json.dumps(mcp_request, indent=2)}")
101
+ logger.info(f"[MCP SSE] Executing tool '{tool_name_for_server}' with session {self.session_id}")
102
+
103
+ try:
104
+ # Prepare headers
105
+ headers = {}
106
+ if self.server_headers:
107
+ headers.update(self.server_headers)
108
+
109
+ # Create SSE client
110
+ client = McpSseClient(
111
+ url=self.server_url,
112
+ session_id=self.session_id,
113
+ headers=headers,
114
+ timeout=self.tool_timeout_sec
115
+ )
116
+
117
+ # Execute tool call via SSE
118
+ result = await client.call_tool(tool_name_for_server, kwargs)
119
+
120
+ # Format the result
121
+ if isinstance(result, dict):
122
+ # Check for content array (common in MCP responses)
123
+ if "content" in result:
124
+ content_items = result["content"]
125
+ if isinstance(content_items, list):
126
+ # Extract text from content items
127
+ text_parts = []
128
+ for item in content_items:
129
+ if isinstance(item, dict):
130
+ if item.get("type") == "text" and "text" in item:
131
+ text_parts.append(item["text"])
132
+ elif "text" in item:
133
+ text_parts.append(item["text"])
134
+ else:
135
+ text_parts.append(json.dumps(item))
136
+ else:
137
+ text_parts.append(str(item))
138
+ return "\n".join(text_parts)
144
139
 
145
- async with session.post(url, json=mcp_request, headers=headers) as response:
146
- auth_header = response.headers.get('WWW-Authenticate') or response.headers.get('Www-Authenticate')
147
- if response.status == 401:
148
- resource_metadata_url = extract_resource_metadata_url(auth_header, self.server_url)
149
- metadata = None
150
- if resource_metadata_url:
151
- metadata = await fetch_resource_metadata_async(
152
- resource_metadata_url,
153
- session=session,
154
- timeout=self.tool_timeout_sec,
155
- )
156
-
157
- # If we couldn't get metadata from the resource_metadata endpoint,
158
- # infer authorization servers from the WWW-Authenticate header and server URL
159
- if not metadata or not metadata.get('authorization_servers'):
160
- inferred_servers = infer_authorization_servers_from_realm(auth_header, self.server_url)
161
- if inferred_servers:
162
- if not metadata:
163
- metadata = {}
164
- metadata['authorization_servers'] = inferred_servers
165
- logger.info(f"Inferred authorization servers for {self.server_url}: {inferred_servers}")
166
-
167
- # Fetch OAuth authorization server metadata from the inferred server
168
- # This avoids CORS issues in the frontend
169
- from alita_sdk.runtime.utils.mcp_oauth import fetch_oauth_authorization_server_metadata
170
- auth_server_metadata = fetch_oauth_authorization_server_metadata(inferred_servers[0], timeout=self.tool_timeout_sec)
171
- if auth_server_metadata:
172
- metadata['oauth_authorization_server'] = auth_server_metadata
173
- logger.info(f"Fetched OAuth metadata for {inferred_servers[0]}")
174
-
175
- raise McpAuthorizationRequired(
176
- message=f"MCP server {self.server_url} requires OAuth authorization",
177
- server_url=canonical_resource(self.server_url),
178
- resource_metadata_url=resource_metadata_url,
179
- www_authenticate=auth_header,
180
- resource_metadata=metadata,
181
- status=response.status,
182
- tool_name=self.name,
183
- )
184
-
185
- # Check for errors first
186
- data = None
187
- if response.status != 200:
188
- error_text = await response.text()
189
-
190
- # Check if this is a "Missing sessionId" error
191
- if response.status == 404 and "sessionId" in error_text:
192
- logger.error(f"[MCP Session] Server requires session but none provided")
193
- logger.error(f"[MCP Session] Frontend must generate a UUID and send it with mcp_tokens")
194
- logger.error(f"[MCP Session] Example: mcp_tokens = {{'server_url': {{'access_token': '...', 'session_id': crypto.randomUUID()}}}}")
195
- raise Exception(f"HTTP {response.status}: {error_text} - sessionId required but not provided by frontend")
196
- else:
197
- raise Exception(f"HTTP {response.status}: {error_text}")
198
-
199
- # Parse response if not already done in retry logic
200
- if data is None:
201
- content_type = response.headers.get('Content-Type', '')
202
- if 'text/event-stream' in content_type:
203
- # Parse SSE format
204
- text = await response.text()
205
- data = self._parse_sse(text)
206
- else:
207
- # Parse regular JSON
208
- data = await response.json()
209
-
210
- logger.debug(f"Response: {json.dumps(data, indent=2)}")
211
-
212
- # Check for MCP error
213
- if "error" in data:
214
- error = data["error"]
215
- error_msg = error.get("message", str(error))
216
- raise Exception(f"MCP Error: {error_msg}")
217
-
218
- # Extract result
219
- result = data.get("result", {})
220
-
221
- # Format the result based on content type
222
- if isinstance(result, dict):
223
- # Check for content array (common in MCP responses)
224
- if "content" in result:
225
- content_items = result["content"]
226
- if isinstance(content_items, list):
227
- # Extract text from content items
228
- text_parts = []
229
- for item in content_items:
230
- if isinstance(item, dict):
231
- if item.get("type") == "text" and "text" in item:
232
- text_parts.append(item["text"])
233
- elif "text" in item:
234
- text_parts.append(item["text"])
235
- else:
236
- text_parts.append(json.dumps(item))
237
- else:
238
- text_parts.append(str(item))
239
- return "\n".join(text_parts)
240
-
241
- # Return formatted JSON if no content field
242
- return json.dumps(result, indent=2)
243
-
244
- # Return as string for other types
245
- return str(result)
246
-
247
- except asyncio.TimeoutError:
248
- raise Exception(f"Tool execution timed out after {self.tool_timeout_sec}s")
249
- except Exception as e:
250
- logger.error(f"Error calling remote MCP tool '{tool_name_for_server}': {e}", exc_info=True)
251
- raise
140
+ # Return formatted JSON if no content field
141
+ return json.dumps(result, indent=2)
142
+
143
+ # Return as string for other types
144
+ return str(result)
145
+
146
+ except Exception as e:
147
+ logger.error(f"[MCP SSE] Tool execution failed: {e}", exc_info=True)
148
+ raise
252
149
 
253
150
  def _parse_sse(self, text: str) -> Dict[str, Any]:
254
151
  """Parse Server-Sent Events (SSE) format response."""
@@ -0,0 +1,405 @@
1
+ """
2
+ MCP SSE (Server-Sent Events) Client
3
+ Handles persistent SSE connections for MCP servers like Atlassian
4
+ """
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ from typing import Dict, Any, Optional, AsyncIterator
9
+ import aiohttp
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class McpSseClient:
15
+ """
16
+ Client for MCP servers using SSE (Server-Sent Events) transport.
17
+
18
+ For Atlassian-style SSE (dual-connection model):
19
+ - GET request opens persistent SSE stream for receiving events
20
+ - POST requests send commands (return 202 Accepted immediately)
21
+ - Responses come via the GET stream
22
+
23
+ This client handles:
24
+ - Opening persistent SSE connection via GET
25
+ - Sending JSON-RPC requests via POST
26
+ - Reading SSE event streams
27
+ - Matching responses to requests by ID
28
+ """
29
+
30
+ def __init__(self, url: str, session_id: str, headers: Optional[Dict[str, str]] = None, timeout: int = 300):
31
+ """
32
+ Initialize SSE client.
33
+
34
+ Args:
35
+ url: Base URL of the MCP SSE server
36
+ session_id: Client-generated UUID for session
37
+ headers: Additional headers (e.g., Authorization)
38
+ timeout: Request timeout in seconds
39
+ """
40
+ self.url = url
41
+ self.session_id = session_id
42
+ self.headers = headers or {}
43
+ self.timeout = timeout
44
+ self.url_with_session = f"{url}?sessionId={session_id}"
45
+ self._stream_task = None
46
+ self._pending_requests = {} # request_id -> asyncio.Future
47
+ self._stream_session = None
48
+ self._stream_response = None
49
+ self._endpoint_ready = asyncio.Event() # Signal when endpoint is received
50
+
51
+ logger.info(f"[MCP SSE Client] Initialized for {url} with session {session_id}")
52
+
53
+ async def _ensure_stream_connected(self):
54
+ """Ensure the GET stream is connected and reading events."""
55
+ if self._stream_task is None or self._stream_task.done():
56
+ logger.info(f"[MCP SSE Client] Opening persistent SSE stream...")
57
+ self._stream_session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=None))
58
+
59
+ headers = {
60
+ "Accept": "text/event-stream",
61
+ **self.headers
62
+ }
63
+
64
+ self._stream_response = await self._stream_session.get(self.url_with_session, headers=headers)
65
+
66
+ logger.info(f"[MCP SSE Client] Stream opened: status={self._stream_response.status}")
67
+
68
+ # Handle 401 Unauthorized - need OAuth
69
+ if self._stream_response.status == 401:
70
+ from ..utils.mcp_oauth import (
71
+ McpAuthorizationRequired,
72
+ canonical_resource,
73
+ extract_resource_metadata_url,
74
+ fetch_resource_metadata_async,
75
+ infer_authorization_servers_from_realm,
76
+ fetch_oauth_authorization_server_metadata
77
+ )
78
+
79
+ auth_header = self._stream_response.headers.get('WWW-Authenticate', '')
80
+ resource_metadata_url = extract_resource_metadata_url(auth_header, self.url)
81
+
82
+ metadata = None
83
+ if resource_metadata_url:
84
+ metadata = await fetch_resource_metadata_async(
85
+ resource_metadata_url,
86
+ session=self._stream_session,
87
+ timeout=30
88
+ )
89
+
90
+ # Infer authorization servers if not in metadata
91
+ if not metadata or not metadata.get('authorization_servers'):
92
+ inferred_servers = infer_authorization_servers_from_realm(auth_header, self.url)
93
+ if inferred_servers:
94
+ if not metadata:
95
+ metadata = {}
96
+ metadata['authorization_servers'] = inferred_servers
97
+ logger.info(f"[MCP SSE Client] Inferred authorization servers: {inferred_servers}")
98
+
99
+ # Fetch OAuth metadata
100
+ auth_server_metadata = fetch_oauth_authorization_server_metadata(inferred_servers[0], timeout=30)
101
+ if auth_server_metadata:
102
+ metadata['oauth_authorization_server'] = auth_server_metadata
103
+ logger.info(f"[MCP SSE Client] Fetched OAuth metadata")
104
+
105
+ raise McpAuthorizationRequired(
106
+ message=f"MCP server {self.url} requires OAuth authorization",
107
+ server_url=canonical_resource(self.url),
108
+ resource_metadata_url=resource_metadata_url,
109
+ www_authenticate=auth_header,
110
+ resource_metadata=metadata,
111
+ status=self._stream_response.status,
112
+ tool_name=self.url,
113
+ )
114
+
115
+ if self._stream_response.status != 200:
116
+ error_text = await self._stream_response.text()
117
+ raise Exception(f"Failed to open SSE stream: HTTP {self._stream_response.status}: {error_text}")
118
+
119
+ # Start background task to read stream
120
+ self._stream_task = asyncio.create_task(self._read_stream())
121
+
122
+ async def _read_stream(self):
123
+ """Background task that continuously reads the SSE stream."""
124
+ logger.info(f"[MCP SSE Client] Starting stream reader...")
125
+
126
+ try:
127
+ buffer = ""
128
+ current_event = {}
129
+
130
+ async for chunk in self._stream_response.content.iter_chunked(1024):
131
+ chunk_str = chunk.decode('utf-8')
132
+ buffer += chunk_str
133
+
134
+ # Process complete lines
135
+ while '\n' in buffer:
136
+ line, buffer = buffer.split('\n', 1)
137
+ line_str = line.strip()
138
+
139
+ # Empty line indicates end of event
140
+ if not line_str:
141
+ if current_event and 'data' in current_event:
142
+ self._process_event(current_event)
143
+ current_event = {}
144
+ continue
145
+
146
+ # Parse SSE fields
147
+ if line_str.startswith('event:'):
148
+ current_event['event'] = line_str[6:].strip()
149
+ elif line_str.startswith('data:'):
150
+ data_str = line_str[5:].strip()
151
+ current_event['data'] = data_str
152
+ elif line_str.startswith('id:'):
153
+ current_event['id'] = line_str[3:].strip()
154
+
155
+ except Exception as e:
156
+ logger.error(f"[MCP SSE Client] Stream reader error: {e}")
157
+ # Fail all pending requests
158
+ for future in self._pending_requests.values():
159
+ if not future.done():
160
+ future.set_exception(e)
161
+ finally:
162
+ logger.info(f"[MCP SSE Client] Stream reader stopped")
163
+
164
+ def _process_event(self, event: Dict[str, str]):
165
+ """Process a complete SSE event."""
166
+ event_type = event.get('event', 'message')
167
+ data_str = event.get('data', '')
168
+
169
+ # Handle 'endpoint' event - server provides the actual session URL to use
170
+ if event_type == 'endpoint':
171
+ # Extract session ID from endpoint URL
172
+ # Format: /v1/sse?sessionId=<uuid>
173
+ if 'sessionId=' in data_str:
174
+ new_session_id = data_str.split('sessionId=')[1].split('&')[0]
175
+ logger.info(f"[MCP SSE Client] Server provided session ID: {new_session_id}")
176
+ self.session_id = new_session_id
177
+ self.url_with_session = f"{self.url}?sessionId={new_session_id}"
178
+ self._endpoint_ready.set() # Signal that we can now send requests
179
+ return
180
+
181
+ # Skip other non-message events
182
+ if event_type != 'message' and not data_str.startswith('{'):
183
+ return
184
+
185
+ if not data_str:
186
+ return
187
+
188
+ try:
189
+ data = json.loads(data_str)
190
+ request_id = data.get('id')
191
+
192
+ logger.debug(f"[MCP SSE Client] Received response for request {request_id}")
193
+
194
+ # Resolve pending request
195
+ if request_id and request_id in self._pending_requests:
196
+ future = self._pending_requests.pop(request_id)
197
+ if not future.done():
198
+ future.set_result(data)
199
+
200
+ except json.JSONDecodeError as e:
201
+ logger.warning(f"[MCP SSE Client] Failed to parse SSE data: {e}, data: {repr(data_str)[:200]}")
202
+
203
+ except Exception as e:
204
+ logger.error(f"[MCP SSE Client] Stream reader error: {e}")
205
+ # Fail all pending requests
206
+ for future in self._pending_requests.values():
207
+ if not future.done():
208
+ future.set_exception(e)
209
+ finally:
210
+ logger.info(f"[MCP SSE Client] Stream reader stopped")
211
+
212
+ async def send_request(self, method: str, params: Optional[Dict[str, Any]] = None, request_id: Optional[str] = None) -> Dict[str, Any]:
213
+ """
214
+ Send a JSON-RPC request and wait for response via SSE stream.
215
+
216
+ Uses dual-connection model:
217
+ 1. GET stream is kept open to receive responses
218
+ 2. POST request sends the command (returns 202 immediately)
219
+ 3. Response comes via the GET stream
220
+
221
+ Args:
222
+ method: JSON-RPC method name (e.g., "tools/list", "tools/call")
223
+ params: Method parameters
224
+ request_id: Optional request ID (auto-generated if not provided)
225
+
226
+ Returns:
227
+ Parsed JSON-RPC response
228
+
229
+ Raises:
230
+ Exception: If request fails or times out
231
+ """
232
+ import time
233
+ if request_id is None:
234
+ request_id = f"{method.replace('/', '_')}_{int(time.time() * 1000)}"
235
+
236
+ request = {
237
+ "jsonrpc": "2.0",
238
+ "id": request_id,
239
+ "method": method,
240
+ "params": params or {}
241
+ }
242
+
243
+ logger.debug(f"[MCP SSE Client] Sending request: {method} (id={request_id})")
244
+
245
+ # Ensure stream is connected
246
+ await self._ensure_stream_connected()
247
+
248
+ # Wait for endpoint event (server provides the actual session ID to use)
249
+ await asyncio.wait_for(self._endpoint_ready.wait(), timeout=10)
250
+
251
+ # Create future for this request
252
+ future = asyncio.Future()
253
+ self._pending_requests[request_id] = future
254
+
255
+ # Send POST request
256
+ headers = {
257
+ "Content-Type": "application/json",
258
+ **self.headers
259
+ }
260
+
261
+ timeout = aiohttp.ClientTimeout(total=30)
262
+
263
+ try:
264
+ async with aiohttp.ClientSession(timeout=timeout) as session:
265
+ async with session.post(self.url_with_session, json=request, headers=headers) as response:
266
+ if response.status == 404:
267
+ error_text = await response.text()
268
+ raise Exception(f"HTTP 404: {error_text}")
269
+
270
+ # 202 is expected - response will come via stream
271
+ if response.status not in [200, 202]:
272
+ error_text = await response.text()
273
+ raise Exception(f"HTTP {response.status}: {error_text}")
274
+
275
+ # Wait for response from stream (with timeout)
276
+ result = await asyncio.wait_for(future, timeout=self.timeout)
277
+
278
+ # Check for JSON-RPC error
279
+ if 'error' in result:
280
+ error = result['error']
281
+ raise Exception(f"MCP Error: {error.get('message', str(error))}")
282
+
283
+ return result
284
+
285
+ except asyncio.TimeoutError:
286
+ self._pending_requests.pop(request_id, None)
287
+ logger.error(f"[MCP SSE Client] Request timeout after {self.timeout}s")
288
+ raise Exception(f"SSE request timeout after {self.timeout}s")
289
+ except Exception as e:
290
+ self._pending_requests.pop(request_id, None)
291
+ logger.error(f"[MCP SSE Client] Request failed: {e}")
292
+ raise
293
+
294
+ async def close(self):
295
+ """Close the persistent SSE stream."""
296
+ logger.info(f"[MCP SSE Client] Closing connection...")
297
+
298
+ # Cancel background stream reader task
299
+ if self._stream_task and not self._stream_task.done():
300
+ self._stream_task.cancel()
301
+ try:
302
+ await self._stream_task
303
+ except (asyncio.CancelledError, Exception) as e:
304
+ logger.debug(f"[MCP SSE Client] Stream task cleanup: {e}")
305
+
306
+ # Close response stream
307
+ if self._stream_response and not self._stream_response.closed:
308
+ try:
309
+ self._stream_response.close()
310
+ except Exception as e:
311
+ logger.debug(f"[MCP SSE Client] Response close error: {e}")
312
+
313
+ # Close session
314
+ if self._stream_session and not self._stream_session.closed:
315
+ try:
316
+ await self._stream_session.close()
317
+ # Give aiohttp time to cleanup
318
+ await asyncio.sleep(0.1)
319
+ except Exception as e:
320
+ logger.debug(f"[MCP SSE Client] Session close error: {e}")
321
+
322
+ logger.info(f"[MCP SSE Client] Connection closed")
323
+
324
+ async def __aenter__(self):
325
+ """Async context manager entry."""
326
+ return self
327
+
328
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
329
+ """Async context manager exit."""
330
+ await self.close()
331
+
332
+ async def initialize(self) -> Dict[str, Any]:
333
+ """
334
+ Send initialize request to establish MCP protocol session.
335
+
336
+ Returns:
337
+ Server capabilities and info
338
+ """
339
+ response = await self.send_request(
340
+ method="initialize",
341
+ params={
342
+ "protocolVersion": "2024-11-05",
343
+ "capabilities": {
344
+ "roots": {"listChanged": True},
345
+ "sampling": {}
346
+ },
347
+ "clientInfo": {
348
+ "name": "Alita MCP Client",
349
+ "version": "1.0.0"
350
+ }
351
+ }
352
+ )
353
+
354
+ logger.info(f"[MCP SSE Client] MCP session initialized")
355
+ return response.get('result', {})
356
+
357
+ async def list_tools(self) -> list:
358
+ """
359
+ Discover available tools from the MCP server.
360
+
361
+ Returns:
362
+ List of tool definitions
363
+ """
364
+ response = await self.send_request(method="tools/list")
365
+ result = response.get('result', {})
366
+ tools = result.get('tools', [])
367
+
368
+ logger.info(f"[MCP SSE Client] Discovered {len(tools)} tools")
369
+ return tools
370
+
371
+ async def list_prompts(self) -> list:
372
+ """
373
+ Discover available prompts from the MCP server.
374
+
375
+ Returns:
376
+ List of prompt definitions
377
+ """
378
+ response = await self.send_request(method="prompts/list")
379
+ result = response.get('result', {})
380
+ prompts = result.get('prompts', [])
381
+
382
+ logger.debug(f"[MCP SSE Client] Discovered {len(prompts)} prompts")
383
+ return prompts
384
+
385
+ async def call_tool(self, tool_name: str, arguments: Dict[str, Any]) -> Any:
386
+ """
387
+ Execute a tool on the MCP server.
388
+
389
+ Args:
390
+ tool_name: Name of the tool to call
391
+ arguments: Tool arguments
392
+
393
+ Returns:
394
+ Tool execution result
395
+ """
396
+ response = await self.send_request(
397
+ method="tools/call",
398
+ params={
399
+ "name": tool_name,
400
+ "arguments": arguments
401
+ }
402
+ )
403
+
404
+ result = response.get('result', {})
405
+ return result
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alita_sdk
3
- Version: 0.3.448
3
+ Version: 0.3.450
4
4
  Summary: SDK for building langchain agents using resources from Alita
5
5
  Author-email: Artem Rozumenko <artyom.rozumenko@gmail.com>, Mikalai Biazruchka <mikalai_biazruchka@epam.com>, Roman Mitusov <roman_mitusov@epam.com>, Ivan Krakhmaliuk <lifedj27@gmail.com>, Artem Dubrovskiy <ad13box@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -24,6 +24,7 @@ Requires-Dist: fastapi==0.115.9
24
24
  Requires-Dist: httpcore==1.0.7
25
25
  Requires-Dist: urllib3>=2
26
26
  Requires-Dist: certifi==2024.8.30
27
+ Requires-Dist: aiohttp>=3.9.0
27
28
  Provides-Extra: runtime
28
29
  Requires-Dist: streamlit>=1.28.0; extra == "runtime"
29
30
  Requires-Dist: langchain_core<0.4.0,>=0.3.76; extra == "runtime"
@@ -103,7 +103,7 @@ alita_sdk/runtime/toolkits/application.py,sha256=HHAKgwKOckxc7EQG-AV7rz4POOzQJKF
103
103
  alita_sdk/runtime/toolkits/artifact.py,sha256=YChNCX4QhVpaQG7Jk4TS-Wl0Aruc4slQ2K21zh9nNO0,3176
104
104
  alita_sdk/runtime/toolkits/configurations.py,sha256=kIDAlnryPQfbZyFxV-9SzN2-Vefzx06TX1BBdIIpN90,141
105
105
  alita_sdk/runtime/toolkits/datasource.py,sha256=qk78OdPoReYPCWwahfkKLbKc4pfsu-061oXRryFLP6I,2498
106
- alita_sdk/runtime/toolkits/mcp.py,sha256=2RpT00q6qKkXHPMkFN6wnqUHYGjYpDAmZ5dWokEXA_s,42172
106
+ alita_sdk/runtime/toolkits/mcp.py,sha256=wa-47obeWm8WrIiaUw5BR40dsR5sLdQKZHzG7MCRnRM,37105
107
107
  alita_sdk/runtime/toolkits/prompt.py,sha256=WIpTkkVYWqIqOWR_LlSWz3ug8uO9tm5jJ7aZYdiGRn0,1192
108
108
  alita_sdk/runtime/toolkits/subgraph.py,sha256=wwUK8JjPXkGzyVZ3tAukmvST6eGbqx_U11rpnmbrvtg,2105
109
109
  alita_sdk/runtime/toolkits/tools.py,sha256=74R0SadMiUJFUe3U5_PJcxzw5m1KMMBiVfVeiowZK6I,13434
@@ -122,7 +122,7 @@ alita_sdk/runtime/tools/llm.py,sha256=iRG_wU4T01LRsjEMPZe5Uah7LiMqDc-vspwkMuQtlt
122
122
  alita_sdk/runtime/tools/loop.py,sha256=uds0WhZvwMxDVFI6MZHrcmMle637cQfBNg682iLxoJA,8335
123
123
  alita_sdk/runtime/tools/loop_output.py,sha256=U4hO9PCQgWlXwOq6jdmCGbegtAxGAPXObSxZQ3z38uk,8069
124
124
  alita_sdk/runtime/tools/mcp_inspect_tool.py,sha256=38X8euaxDbEGjcfp6ElvExZalpZun6QEr6ZEW4nU5pQ,11496
125
- alita_sdk/runtime/tools/mcp_remote_tool.py,sha256=0-wqqq2ZY9cgGeTd6ao17e393YgD99n3X-k2pXlaR2g,13352
125
+ alita_sdk/runtime/tools/mcp_remote_tool.py,sha256=Bpgu5r97QzDvNert8Vv424DlolMR0rXsac3Qrg2iAig,7059
126
126
  alita_sdk/runtime/tools/mcp_server_tool.py,sha256=-xO3H6BM63KaIV1CdcQKPVE0WPigiqOgFZDX7m2_yGs,4419
127
127
  alita_sdk/runtime/tools/pgvector_search.py,sha256=NN2BGAnq4SsDHIhUcFZ8d_dbEOM8QwB0UwpsWCYruXU,11692
128
128
  alita_sdk/runtime/tools/prompt.py,sha256=nJafb_e5aOM1Rr3qGFCR-SKziU9uCsiP2okIMs9PppM,741
@@ -137,6 +137,7 @@ alita_sdk/runtime/utils/constants.py,sha256=Xntx1b_uxUzT4clwqHA_U6K8y5bBqf_4lSQw
137
137
  alita_sdk/runtime/utils/evaluate.py,sha256=iM1P8gzBLHTuSCe85_Ng_h30m52hFuGuhNXJ7kB1tgI,1872
138
138
  alita_sdk/runtime/utils/logging.py,sha256=svPyiW8ztDfhqHFITv5FBCj8UhLxz6hWcqGIY6wpJJE,3331
139
139
  alita_sdk/runtime/utils/mcp_oauth.py,sha256=Ynoa_C_G5WXL_tlcdol2wBLgQauyvIPX0isCJKsvWMs,6151
140
+ alita_sdk/runtime/utils/mcp_sse_client.py,sha256=cSOyfnOoxVorfIePQ4k-BmOutBOeL3YDhTNxWtFREA0,16251
140
141
  alita_sdk/runtime/utils/save_dataframe.py,sha256=i-E1wp-t4wb17Zq3nA3xYwgSILjoXNizaQAA9opWvxY,1576
141
142
  alita_sdk/runtime/utils/streamlit.py,sha256=yIb4YIGH8HRAXZtZlywjxI07Xdcb5eUt7rMA-elFTdc,107261
142
143
  alita_sdk/runtime/utils/toolkit_runtime.py,sha256=MU63Fpxj0b5_r1IUUc0Q3-PN9VwL7rUxp2MRR4tmYR8,5136
@@ -360,8 +361,8 @@ alita_sdk/tools/zephyr_scale/api_wrapper.py,sha256=kT0TbmMvuKhDUZc0i7KO18O38JM9S
360
361
  alita_sdk/tools/zephyr_squad/__init__.py,sha256=0ne8XLJEQSLOWfzd2HdnqOYmQlUliKHbBED5kW_Vias,2895
361
362
  alita_sdk/tools/zephyr_squad/api_wrapper.py,sha256=kmw_xol8YIYFplBLWTqP_VKPRhL_1ItDD0_vXTe_UuI,14906
362
363
  alita_sdk/tools/zephyr_squad/zephyr_squad_cloud_client.py,sha256=R371waHsms4sllHCbijKYs90C-9Yu0sSR3N4SUfQOgU,5066
363
- alita_sdk-0.3.448.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
364
- alita_sdk-0.3.448.dist-info/METADATA,sha256=FsoPHn8uhMeNXFvo4DkRBzyweY-GNMGH_-xJtnAVX2c,19071
365
- alita_sdk-0.3.448.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
366
- alita_sdk-0.3.448.dist-info/top_level.txt,sha256=0vJYy5p_jK6AwVb1aqXr7Kgqgk3WDtQ6t5C-XI9zkmg,10
367
- alita_sdk-0.3.448.dist-info/RECORD,,
364
+ alita_sdk-0.3.450.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
365
+ alita_sdk-0.3.450.dist-info/METADATA,sha256=ef9IMngx_rnicjlwr5pbK6rWNCbb8oGxTS4NbiAatuw,19101
366
+ alita_sdk-0.3.450.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
367
+ alita_sdk-0.3.450.dist-info/top_level.txt,sha256=0vJYy5p_jK6AwVb1aqXr7Kgqgk3WDtQ6t5C-XI9zkmg,10
368
+ alita_sdk-0.3.450.dist-info/RECORD,,