alita-sdk 0.3.263__py3-none-any.whl → 0.3.499__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (248) hide show
  1. alita_sdk/cli/__init__.py +10 -0
  2. alita_sdk/cli/__main__.py +17 -0
  3. alita_sdk/cli/agent/__init__.py +5 -0
  4. alita_sdk/cli/agent/default.py +258 -0
  5. alita_sdk/cli/agent_executor.py +155 -0
  6. alita_sdk/cli/agent_loader.py +215 -0
  7. alita_sdk/cli/agent_ui.py +228 -0
  8. alita_sdk/cli/agents.py +3601 -0
  9. alita_sdk/cli/callbacks.py +647 -0
  10. alita_sdk/cli/cli.py +168 -0
  11. alita_sdk/cli/config.py +306 -0
  12. alita_sdk/cli/context/__init__.py +30 -0
  13. alita_sdk/cli/context/cleanup.py +198 -0
  14. alita_sdk/cli/context/manager.py +731 -0
  15. alita_sdk/cli/context/message.py +285 -0
  16. alita_sdk/cli/context/strategies.py +289 -0
  17. alita_sdk/cli/context/token_estimation.py +127 -0
  18. alita_sdk/cli/formatting.py +182 -0
  19. alita_sdk/cli/input_handler.py +419 -0
  20. alita_sdk/cli/inventory.py +1256 -0
  21. alita_sdk/cli/mcp_loader.py +315 -0
  22. alita_sdk/cli/toolkit.py +327 -0
  23. alita_sdk/cli/toolkit_loader.py +85 -0
  24. alita_sdk/cli/tools/__init__.py +43 -0
  25. alita_sdk/cli/tools/approval.py +224 -0
  26. alita_sdk/cli/tools/filesystem.py +1751 -0
  27. alita_sdk/cli/tools/planning.py +389 -0
  28. alita_sdk/cli/tools/terminal.py +414 -0
  29. alita_sdk/community/__init__.py +64 -8
  30. alita_sdk/community/inventory/__init__.py +224 -0
  31. alita_sdk/community/inventory/config.py +257 -0
  32. alita_sdk/community/inventory/enrichment.py +2137 -0
  33. alita_sdk/community/inventory/extractors.py +1469 -0
  34. alita_sdk/community/inventory/ingestion.py +3172 -0
  35. alita_sdk/community/inventory/knowledge_graph.py +1457 -0
  36. alita_sdk/community/inventory/parsers/__init__.py +218 -0
  37. alita_sdk/community/inventory/parsers/base.py +295 -0
  38. alita_sdk/community/inventory/parsers/csharp_parser.py +907 -0
  39. alita_sdk/community/inventory/parsers/go_parser.py +851 -0
  40. alita_sdk/community/inventory/parsers/html_parser.py +389 -0
  41. alita_sdk/community/inventory/parsers/java_parser.py +593 -0
  42. alita_sdk/community/inventory/parsers/javascript_parser.py +629 -0
  43. alita_sdk/community/inventory/parsers/kotlin_parser.py +768 -0
  44. alita_sdk/community/inventory/parsers/markdown_parser.py +362 -0
  45. alita_sdk/community/inventory/parsers/python_parser.py +604 -0
  46. alita_sdk/community/inventory/parsers/rust_parser.py +858 -0
  47. alita_sdk/community/inventory/parsers/swift_parser.py +832 -0
  48. alita_sdk/community/inventory/parsers/text_parser.py +322 -0
  49. alita_sdk/community/inventory/parsers/yaml_parser.py +370 -0
  50. alita_sdk/community/inventory/patterns/__init__.py +61 -0
  51. alita_sdk/community/inventory/patterns/ast_adapter.py +380 -0
  52. alita_sdk/community/inventory/patterns/loader.py +348 -0
  53. alita_sdk/community/inventory/patterns/registry.py +198 -0
  54. alita_sdk/community/inventory/presets.py +535 -0
  55. alita_sdk/community/inventory/retrieval.py +1403 -0
  56. alita_sdk/community/inventory/toolkit.py +173 -0
  57. alita_sdk/community/inventory/visualize.py +1370 -0
  58. alita_sdk/configurations/__init__.py +10 -0
  59. alita_sdk/configurations/ado.py +4 -2
  60. alita_sdk/configurations/azure_search.py +1 -1
  61. alita_sdk/configurations/bigquery.py +1 -1
  62. alita_sdk/configurations/bitbucket.py +94 -2
  63. alita_sdk/configurations/browser.py +18 -0
  64. alita_sdk/configurations/carrier.py +19 -0
  65. alita_sdk/configurations/confluence.py +96 -1
  66. alita_sdk/configurations/delta_lake.py +1 -1
  67. alita_sdk/configurations/figma.py +0 -5
  68. alita_sdk/configurations/github.py +65 -1
  69. alita_sdk/configurations/gitlab.py +79 -0
  70. alita_sdk/configurations/google_places.py +17 -0
  71. alita_sdk/configurations/jira.py +103 -0
  72. alita_sdk/configurations/postman.py +1 -1
  73. alita_sdk/configurations/qtest.py +1 -3
  74. alita_sdk/configurations/report_portal.py +19 -0
  75. alita_sdk/configurations/salesforce.py +19 -0
  76. alita_sdk/configurations/service_now.py +1 -12
  77. alita_sdk/configurations/sharepoint.py +19 -0
  78. alita_sdk/configurations/sonar.py +18 -0
  79. alita_sdk/configurations/sql.py +20 -0
  80. alita_sdk/configurations/testio.py +18 -0
  81. alita_sdk/configurations/testrail.py +88 -0
  82. alita_sdk/configurations/xray.py +94 -1
  83. alita_sdk/configurations/zephyr_enterprise.py +94 -1
  84. alita_sdk/configurations/zephyr_essential.py +95 -0
  85. alita_sdk/runtime/clients/artifact.py +12 -2
  86. alita_sdk/runtime/clients/client.py +235 -66
  87. alita_sdk/runtime/clients/mcp_discovery.py +342 -0
  88. alita_sdk/runtime/clients/mcp_manager.py +262 -0
  89. alita_sdk/runtime/clients/sandbox_client.py +373 -0
  90. alita_sdk/runtime/langchain/assistant.py +123 -17
  91. alita_sdk/runtime/langchain/constants.py +8 -1
  92. alita_sdk/runtime/langchain/document_loaders/AlitaDocxMammothLoader.py +315 -3
  93. alita_sdk/runtime/langchain/document_loaders/AlitaExcelLoader.py +209 -31
  94. alita_sdk/runtime/langchain/document_loaders/AlitaImageLoader.py +1 -1
  95. alita_sdk/runtime/langchain/document_loaders/AlitaJSONLoader.py +8 -2
  96. alita_sdk/runtime/langchain/document_loaders/AlitaMarkdownLoader.py +66 -0
  97. alita_sdk/runtime/langchain/document_loaders/AlitaPDFLoader.py +79 -10
  98. alita_sdk/runtime/langchain/document_loaders/AlitaPowerPointLoader.py +52 -15
  99. alita_sdk/runtime/langchain/document_loaders/AlitaPythonLoader.py +9 -0
  100. alita_sdk/runtime/langchain/document_loaders/AlitaTableLoader.py +1 -4
  101. alita_sdk/runtime/langchain/document_loaders/AlitaTextLoader.py +15 -2
  102. alita_sdk/runtime/langchain/document_loaders/ImageParser.py +30 -0
  103. alita_sdk/runtime/langchain/document_loaders/constants.py +187 -40
  104. alita_sdk/runtime/langchain/interfaces/llm_processor.py +4 -2
  105. alita_sdk/runtime/langchain/langraph_agent.py +406 -91
  106. alita_sdk/runtime/langchain/utils.py +51 -8
  107. alita_sdk/runtime/llms/preloaded.py +2 -6
  108. alita_sdk/runtime/models/mcp_models.py +61 -0
  109. alita_sdk/runtime/toolkits/__init__.py +26 -0
  110. alita_sdk/runtime/toolkits/application.py +9 -2
  111. alita_sdk/runtime/toolkits/artifact.py +19 -7
  112. alita_sdk/runtime/toolkits/datasource.py +13 -6
  113. alita_sdk/runtime/toolkits/mcp.py +780 -0
  114. alita_sdk/runtime/toolkits/planning.py +178 -0
  115. alita_sdk/runtime/toolkits/subgraph.py +11 -6
  116. alita_sdk/runtime/toolkits/tools.py +214 -60
  117. alita_sdk/runtime/toolkits/vectorstore.py +9 -4
  118. alita_sdk/runtime/tools/__init__.py +22 -0
  119. alita_sdk/runtime/tools/application.py +16 -4
  120. alita_sdk/runtime/tools/artifact.py +312 -19
  121. alita_sdk/runtime/tools/function.py +100 -4
  122. alita_sdk/runtime/tools/graph.py +81 -0
  123. alita_sdk/runtime/tools/image_generation.py +212 -0
  124. alita_sdk/runtime/tools/llm.py +539 -180
  125. alita_sdk/runtime/tools/mcp_inspect_tool.py +284 -0
  126. alita_sdk/runtime/tools/mcp_remote_tool.py +181 -0
  127. alita_sdk/runtime/tools/mcp_server_tool.py +3 -1
  128. alita_sdk/runtime/tools/planning/__init__.py +36 -0
  129. alita_sdk/runtime/tools/planning/models.py +246 -0
  130. alita_sdk/runtime/tools/planning/wrapper.py +607 -0
  131. alita_sdk/runtime/tools/router.py +2 -1
  132. alita_sdk/runtime/tools/sandbox.py +375 -0
  133. alita_sdk/runtime/tools/vectorstore.py +62 -63
  134. alita_sdk/runtime/tools/vectorstore_base.py +156 -85
  135. alita_sdk/runtime/utils/AlitaCallback.py +106 -20
  136. alita_sdk/runtime/utils/mcp_client.py +465 -0
  137. alita_sdk/runtime/utils/mcp_oauth.py +244 -0
  138. alita_sdk/runtime/utils/mcp_sse_client.py +405 -0
  139. alita_sdk/runtime/utils/mcp_tools_discovery.py +124 -0
  140. alita_sdk/runtime/utils/streamlit.py +41 -14
  141. alita_sdk/runtime/utils/toolkit_utils.py +28 -9
  142. alita_sdk/runtime/utils/utils.py +14 -0
  143. alita_sdk/tools/__init__.py +78 -35
  144. alita_sdk/tools/ado/__init__.py +0 -1
  145. alita_sdk/tools/ado/repos/__init__.py +10 -6
  146. alita_sdk/tools/ado/repos/repos_wrapper.py +12 -11
  147. alita_sdk/tools/ado/test_plan/__init__.py +10 -7
  148. alita_sdk/tools/ado/test_plan/test_plan_wrapper.py +56 -23
  149. alita_sdk/tools/ado/wiki/__init__.py +10 -11
  150. alita_sdk/tools/ado/wiki/ado_wrapper.py +114 -28
  151. alita_sdk/tools/ado/work_item/__init__.py +10 -11
  152. alita_sdk/tools/ado/work_item/ado_wrapper.py +63 -10
  153. alita_sdk/tools/advanced_jira_mining/__init__.py +10 -7
  154. alita_sdk/tools/aws/delta_lake/__init__.py +13 -11
  155. alita_sdk/tools/azure_ai/search/__init__.py +11 -7
  156. alita_sdk/tools/base_indexer_toolkit.py +392 -86
  157. alita_sdk/tools/bitbucket/__init__.py +18 -11
  158. alita_sdk/tools/bitbucket/api_wrapper.py +52 -9
  159. alita_sdk/tools/bitbucket/cloud_api_wrapper.py +5 -5
  160. alita_sdk/tools/browser/__init__.py +40 -16
  161. alita_sdk/tools/browser/crawler.py +3 -1
  162. alita_sdk/tools/browser/utils.py +15 -6
  163. alita_sdk/tools/carrier/__init__.py +17 -17
  164. alita_sdk/tools/carrier/backend_reports_tool.py +8 -4
  165. alita_sdk/tools/carrier/excel_reporter.py +8 -4
  166. alita_sdk/tools/chunkers/__init__.py +3 -1
  167. alita_sdk/tools/chunkers/code/codeparser.py +1 -1
  168. alita_sdk/tools/chunkers/sematic/json_chunker.py +1 -0
  169. alita_sdk/tools/chunkers/sematic/markdown_chunker.py +97 -6
  170. alita_sdk/tools/chunkers/sematic/proposal_chunker.py +1 -1
  171. alita_sdk/tools/chunkers/universal_chunker.py +270 -0
  172. alita_sdk/tools/cloud/aws/__init__.py +9 -6
  173. alita_sdk/tools/cloud/azure/__init__.py +9 -6
  174. alita_sdk/tools/cloud/gcp/__init__.py +9 -6
  175. alita_sdk/tools/cloud/k8s/__init__.py +9 -6
  176. alita_sdk/tools/code/linter/__init__.py +7 -7
  177. alita_sdk/tools/code/loaders/codesearcher.py +3 -2
  178. alita_sdk/tools/code/sonar/__init__.py +18 -12
  179. alita_sdk/tools/code_indexer_toolkit.py +199 -0
  180. alita_sdk/tools/confluence/__init__.py +14 -11
  181. alita_sdk/tools/confluence/api_wrapper.py +198 -58
  182. alita_sdk/tools/confluence/loader.py +10 -0
  183. alita_sdk/tools/custom_open_api/__init__.py +9 -4
  184. alita_sdk/tools/elastic/__init__.py +8 -7
  185. alita_sdk/tools/elitea_base.py +543 -64
  186. alita_sdk/tools/figma/__init__.py +10 -8
  187. alita_sdk/tools/figma/api_wrapper.py +352 -153
  188. alita_sdk/tools/github/__init__.py +13 -11
  189. alita_sdk/tools/github/api_wrapper.py +9 -26
  190. alita_sdk/tools/github/github_client.py +75 -12
  191. alita_sdk/tools/github/schemas.py +2 -1
  192. alita_sdk/tools/gitlab/__init__.py +11 -10
  193. alita_sdk/tools/gitlab/api_wrapper.py +135 -45
  194. alita_sdk/tools/gitlab_org/__init__.py +11 -9
  195. alita_sdk/tools/google/bigquery/__init__.py +12 -13
  196. alita_sdk/tools/google_places/__init__.py +18 -10
  197. alita_sdk/tools/jira/__init__.py +14 -8
  198. alita_sdk/tools/jira/api_wrapper.py +315 -168
  199. alita_sdk/tools/keycloak/__init__.py +8 -7
  200. alita_sdk/tools/localgit/local_git.py +56 -54
  201. alita_sdk/tools/memory/__init__.py +27 -11
  202. alita_sdk/tools/non_code_indexer_toolkit.py +7 -2
  203. alita_sdk/tools/ocr/__init__.py +8 -7
  204. alita_sdk/tools/openapi/__init__.py +10 -1
  205. alita_sdk/tools/pandas/__init__.py +8 -7
  206. alita_sdk/tools/pandas/api_wrapper.py +7 -25
  207. alita_sdk/tools/postman/__init__.py +8 -10
  208. alita_sdk/tools/postman/api_wrapper.py +19 -8
  209. alita_sdk/tools/postman/postman_analysis.py +8 -1
  210. alita_sdk/tools/pptx/__init__.py +8 -9
  211. alita_sdk/tools/qtest/__init__.py +19 -13
  212. alita_sdk/tools/qtest/api_wrapper.py +1784 -88
  213. alita_sdk/tools/rally/__init__.py +10 -9
  214. alita_sdk/tools/report_portal/__init__.py +20 -15
  215. alita_sdk/tools/salesforce/__init__.py +19 -15
  216. alita_sdk/tools/servicenow/__init__.py +14 -11
  217. alita_sdk/tools/sharepoint/__init__.py +14 -13
  218. alita_sdk/tools/sharepoint/api_wrapper.py +179 -39
  219. alita_sdk/tools/sharepoint/authorization_helper.py +191 -1
  220. alita_sdk/tools/sharepoint/utils.py +8 -2
  221. alita_sdk/tools/slack/__init__.py +10 -7
  222. alita_sdk/tools/sql/__init__.py +19 -18
  223. alita_sdk/tools/sql/api_wrapper.py +71 -23
  224. alita_sdk/tools/testio/__init__.py +18 -12
  225. alita_sdk/tools/testrail/__init__.py +10 -10
  226. alita_sdk/tools/testrail/api_wrapper.py +213 -45
  227. alita_sdk/tools/utils/__init__.py +28 -4
  228. alita_sdk/tools/utils/content_parser.py +181 -61
  229. alita_sdk/tools/utils/text_operations.py +254 -0
  230. alita_sdk/tools/vector_adapters/VectorStoreAdapter.py +83 -27
  231. alita_sdk/tools/xray/__init__.py +12 -7
  232. alita_sdk/tools/xray/api_wrapper.py +58 -113
  233. alita_sdk/tools/zephyr/__init__.py +9 -6
  234. alita_sdk/tools/zephyr_enterprise/__init__.py +13 -8
  235. alita_sdk/tools/zephyr_enterprise/api_wrapper.py +17 -7
  236. alita_sdk/tools/zephyr_essential/__init__.py +13 -9
  237. alita_sdk/tools/zephyr_essential/api_wrapper.py +289 -47
  238. alita_sdk/tools/zephyr_essential/client.py +6 -4
  239. alita_sdk/tools/zephyr_scale/__init__.py +10 -7
  240. alita_sdk/tools/zephyr_scale/api_wrapper.py +6 -2
  241. alita_sdk/tools/zephyr_squad/__init__.py +9 -6
  242. {alita_sdk-0.3.263.dist-info → alita_sdk-0.3.499.dist-info}/METADATA +180 -33
  243. alita_sdk-0.3.499.dist-info/RECORD +433 -0
  244. alita_sdk-0.3.499.dist-info/entry_points.txt +2 -0
  245. alita_sdk-0.3.263.dist-info/RECORD +0 -342
  246. {alita_sdk-0.3.263.dist-info → alita_sdk-0.3.499.dist-info}/WHEEL +0 -0
  247. {alita_sdk-0.3.263.dist-info → alita_sdk-0.3.499.dist-info}/licenses/LICENSE +0 -0
  248. {alita_sdk-0.3.263.dist-info → alita_sdk-0.3.499.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,244 @@
1
+ import json
2
+ import logging
3
+ import re
4
+ from typing import Any, Dict, Optional
5
+ from urllib.parse import urlparse
6
+
7
+ import requests
8
+ from langchain_core.tools import ToolException
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class McpAuthorizationRequired(ToolException):
14
+ """Raised when an MCP server requires OAuth authorization before use."""
15
+
16
+ def __init__(
17
+ self,
18
+ message: str,
19
+ server_url: str,
20
+ resource_metadata_url: Optional[str] = None,
21
+ www_authenticate: Optional[str] = None,
22
+ resource_metadata: Optional[Dict[str, Any]] = None,
23
+ status: Optional[int] = None,
24
+ tool_name: Optional[str] = None,
25
+ ):
26
+ super().__init__(message)
27
+ self.server_url = server_url
28
+ self.resource_metadata_url = resource_metadata_url
29
+ self.www_authenticate = www_authenticate
30
+ self.resource_metadata = resource_metadata
31
+ self.status = status
32
+ self.tool_name = tool_name
33
+
34
+ def to_dict(self) -> Dict[str, Any]:
35
+ return {
36
+ "message": str(self),
37
+ "server_url": self.server_url,
38
+ "resource_metadata_url": self.resource_metadata_url,
39
+ "www_authenticate": self.www_authenticate,
40
+ "resource_metadata": self.resource_metadata,
41
+ "status": self.status,
42
+ "tool_name": self.tool_name,
43
+ }
44
+
45
+
46
+ def extract_resource_metadata_url(www_authenticate: Optional[str], server_url: Optional[str] = None) -> Optional[str]:
47
+ """
48
+ Pull the resource_metadata URL from a WWW-Authenticate header if present.
49
+ If not found and server_url is provided, try to construct resource metadata URLs.
50
+ """
51
+ if not www_authenticate and not server_url:
52
+ return None
53
+
54
+ # RFC9728 returns `resource_metadata="<url>"` inside the header value
55
+ if www_authenticate:
56
+ match = re.search(r'resource_metadata\s*=\s*\"?([^\", ]+)\"?', www_authenticate)
57
+ if match:
58
+ return match.group(1)
59
+
60
+ # For servers that don't provide resource_metadata in WWW-Authenticate,
61
+ # we'll return None and rely on inferring authorization servers from the realm
62
+ # or using well-known OAuth discovery endpoints directly
63
+ return None
64
+
65
+
66
+ def fetch_oauth_authorization_server_metadata(base_url: str, timeout: int = 10) -> Optional[Dict[str, Any]]:
67
+ """
68
+ Fetch OAuth authorization server metadata from well-known endpoints.
69
+ Tries both oauth-authorization-server and openid-configuration discovery endpoints.
70
+ """
71
+ discovery_endpoints = [
72
+ f"{base_url}/.well-known/oauth-authorization-server",
73
+ f"{base_url}/.well-known/openid-configuration",
74
+ ]
75
+
76
+ for endpoint in discovery_endpoints:
77
+ try:
78
+ resp = requests.get(endpoint, timeout=timeout)
79
+ if resp.status_code == 200:
80
+ return resp.json()
81
+ except Exception as exc:
82
+ logger.debug(f"Failed to fetch OAuth metadata from {endpoint}: {exc}")
83
+ continue
84
+
85
+ return None
86
+
87
+
88
+ def infer_authorization_servers_from_realm(www_authenticate: Optional[str], server_url: str) -> Optional[list]:
89
+ """
90
+ Infer authorization server URLs from WWW-Authenticate realm or server URL.
91
+ This is used when the server doesn't provide resource_metadata endpoint.
92
+ """
93
+ if not www_authenticate and not server_url:
94
+ return None
95
+
96
+ authorization_servers = []
97
+
98
+ # Try to extract realm from WWW-Authenticate header
99
+ realm = None
100
+ if www_authenticate:
101
+ realm_match = re.search(r'realm\s*=\s*\"([^\"]+)\"', www_authenticate)
102
+ if realm_match:
103
+ realm = realm_match.group(1)
104
+
105
+ # Parse the server URL to get base domain
106
+ parsed = urlparse(server_url)
107
+ base_url = f"{parsed.scheme}://{parsed.netloc}"
108
+
109
+ # Return the base authorization server URL (not the discovery endpoint)
110
+ # The client will append .well-known paths when fetching metadata
111
+ authorization_servers.append(base_url)
112
+
113
+ return authorization_servers if authorization_servers else None
114
+
115
+
116
+ def fetch_resource_metadata(resource_metadata_url: str, timeout: int = 10) -> Optional[Dict[str, Any]]:
117
+ """Fetch and parse the protected resource metadata document."""
118
+ try:
119
+ resp = requests.get(resource_metadata_url, timeout=timeout)
120
+ resp.raise_for_status()
121
+ return resp.json()
122
+ except Exception as exc: # broad catch – we want to surface auth requirement even if this fails
123
+ logger.warning("Failed to fetch resource metadata from %s: %s", resource_metadata_url, exc)
124
+ return None
125
+
126
+
127
+ async def fetch_resource_metadata_async(resource_metadata_url: str, session=None, timeout: int = 10) -> Optional[Dict[str, Any]]:
128
+ """Async variant for fetching protected resource metadata."""
129
+ try:
130
+ import aiohttp
131
+
132
+ client_timeout = aiohttp.ClientTimeout(total=timeout)
133
+ if session:
134
+ async with session.get(resource_metadata_url, timeout=client_timeout) as resp:
135
+ text = await resp.text()
136
+ else:
137
+ async with aiohttp.ClientSession(timeout=client_timeout) as local_session:
138
+ async with local_session.get(resource_metadata_url) as resp:
139
+ text = await resp.text()
140
+
141
+ try:
142
+ return json.loads(text)
143
+ except json.JSONDecodeError:
144
+ logger.warning("Resource metadata at %s is not valid JSON: %s", resource_metadata_url, text[:200])
145
+ return None
146
+ except Exception as exc:
147
+ logger.warning("Failed to fetch resource metadata from %s: %s", resource_metadata_url, exc)
148
+ return None
149
+
150
+
151
+ def canonical_resource(server_url: str) -> str:
152
+ """Produce a canonical resource identifier for the MCP server."""
153
+ parsed = urlparse(server_url)
154
+ # Normalize scheme/host casing per RFC guidance
155
+ normalized = parsed._replace(
156
+ scheme=parsed.scheme.lower(),
157
+ netloc=parsed.netloc.lower(),
158
+ )
159
+ resource = normalized.geturl()
160
+
161
+ # Prefer form without trailing slash unless path is meaningful
162
+ if resource.endswith("/") and parsed.path in ("", "/"):
163
+ resource = resource[:-1]
164
+ return resource
165
+
166
+
167
+ def exchange_oauth_token(
168
+ token_endpoint: str,
169
+ code: str,
170
+ redirect_uri: str,
171
+ client_id: str,
172
+ client_secret: Optional[str] = None,
173
+ code_verifier: Optional[str] = None,
174
+ scope: Optional[str] = None,
175
+ timeout: int = 30,
176
+ ) -> Dict[str, Any]:
177
+ """
178
+ Exchange an OAuth authorization code for access tokens.
179
+
180
+ This function performs the OAuth token exchange on the server side,
181
+ avoiding CORS issues that would occur if done from a browser.
182
+
183
+ Args:
184
+ token_endpoint: OAuth token endpoint URL
185
+ code: Authorization code from OAuth provider
186
+ redirect_uri: Redirect URI used in authorization request
187
+ client_id: OAuth client ID
188
+ client_secret: OAuth client secret (optional for public clients)
189
+ code_verifier: PKCE code verifier (optional)
190
+ scope: OAuth scope (optional)
191
+ timeout: Request timeout in seconds
192
+
193
+ Returns:
194
+ Token response from OAuth provider containing access_token, etc.
195
+
196
+ Raises:
197
+ requests.RequestException: If the HTTP request fails
198
+ ValueError: If the token exchange fails
199
+ """
200
+ # Build the token request body
201
+ token_body = {
202
+ "grant_type": "authorization_code",
203
+ "code": code,
204
+ "redirect_uri": redirect_uri,
205
+ "client_id": client_id,
206
+ }
207
+
208
+ if client_secret:
209
+ token_body["client_secret"] = client_secret
210
+ if code_verifier:
211
+ token_body["code_verifier"] = code_verifier
212
+ if scope:
213
+ token_body["scope"] = scope
214
+
215
+ logger.info(f"MCP OAuth: exchanging code at {token_endpoint}")
216
+
217
+ # Make the token exchange request
218
+ response = requests.post(
219
+ token_endpoint,
220
+ data=token_body,
221
+ headers={
222
+ "Content-Type": "application/x-www-form-urlencoded",
223
+ "Accept": "application/json",
224
+ },
225
+ timeout=timeout
226
+ )
227
+
228
+ # Try to parse as JSON
229
+ try:
230
+ token_data = response.json()
231
+ except Exception:
232
+ # Some providers return URL-encoded response
233
+ from urllib.parse import parse_qs
234
+ token_data = {k: v[0] if len(v) == 1 else v
235
+ for k, v in parse_qs(response.text).items()}
236
+
237
+ if response.ok:
238
+ logger.info("MCP OAuth: token exchange successful")
239
+ return token_data
240
+ else:
241
+ error_msg = token_data.get("error_description") or token_data.get("error") or response.text
242
+ logger.error(f"MCP OAuth: token exchange failed - {response.status_code}: {error_msg}")
243
+ raise ValueError(f"Token exchange failed: {error_msg}")
244
+
@@ -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