alita-sdk 0.3.435__py3-none-any.whl → 0.3.449__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/runtime/clients/client.py +39 -7
- alita_sdk/runtime/langchain/assistant.py +10 -2
- alita_sdk/runtime/models/mcp_models.py +4 -0
- alita_sdk/runtime/toolkits/mcp.py +255 -150
- alita_sdk/runtime/toolkits/tools.py +43 -2
- alita_sdk/runtime/tools/mcp_remote_tool.py +166 -0
- alita_sdk/runtime/tools/mcp_server_tool.py +9 -76
- alita_sdk/runtime/utils/mcp_oauth.py +164 -0
- alita_sdk/runtime/utils/mcp_sse_client.py +347 -0
- alita_sdk/runtime/utils/toolkit_utils.py +9 -2
- {alita_sdk-0.3.435.dist-info → alita_sdk-0.3.449.dist-info}/METADATA +2 -1
- {alita_sdk-0.3.435.dist-info → alita_sdk-0.3.449.dist-info}/RECORD +15 -12
- {alita_sdk-0.3.435.dist-info → alita_sdk-0.3.449.dist-info}/WHEEL +0 -0
- {alita_sdk-0.3.435.dist-info → alita_sdk-0.3.449.dist-info}/licenses/LICENSE +0 -0
- {alita_sdk-0.3.435.dist-info → alita_sdk-0.3.449.dist-info}/top_level.txt +0 -0
|
@@ -5,21 +5,145 @@ Following MCP specification: https://modelcontextprotocol.io/specification/2025-
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import logging
|
|
8
|
+
import re
|
|
8
9
|
import requests
|
|
9
|
-
|
|
10
|
+
import asyncio
|
|
11
|
+
from typing import List, Optional, Any, Dict, Literal, ClassVar, Union
|
|
10
12
|
|
|
11
13
|
from langchain_core.tools import BaseToolkit, BaseTool
|
|
12
14
|
from pydantic import BaseModel, ConfigDict, Field
|
|
13
15
|
|
|
14
16
|
from ..tools.mcp_server_tool import McpServerTool
|
|
17
|
+
from ..tools.mcp_remote_tool import McpRemoteTool
|
|
15
18
|
from ..tools.mcp_inspect_tool import McpInspectTool
|
|
16
19
|
from ...tools.utils import TOOLKIT_SPLITTER, clean_string
|
|
17
20
|
from ..models.mcp_models import McpConnectionConfig
|
|
21
|
+
from ..utils.mcp_sse_client import McpSseClient
|
|
22
|
+
from ..utils.mcp_oauth import (
|
|
23
|
+
McpAuthorizationRequired,
|
|
24
|
+
canonical_resource,
|
|
25
|
+
extract_resource_metadata_url,
|
|
26
|
+
fetch_resource_metadata,
|
|
27
|
+
infer_authorization_servers_from_realm,
|
|
28
|
+
)
|
|
18
29
|
|
|
19
30
|
logger = logging.getLogger(__name__)
|
|
20
31
|
|
|
21
32
|
name = "mcp"
|
|
22
33
|
|
|
34
|
+
def safe_int(value, default):
|
|
35
|
+
"""Convert value to int, handling string inputs."""
|
|
36
|
+
if value is None:
|
|
37
|
+
return default
|
|
38
|
+
try:
|
|
39
|
+
return int(value)
|
|
40
|
+
except (ValueError, TypeError):
|
|
41
|
+
logger.warning(f"Invalid integer value '{value}', using default {default}")
|
|
42
|
+
return default
|
|
43
|
+
|
|
44
|
+
def optimize_tool_name(prefix: str, tool_name: str, max_total_length: int = 64) -> str:
|
|
45
|
+
"""
|
|
46
|
+
Optimize tool name to fit within max_total_length while preserving meaning.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
prefix: The toolkit prefix (already cleaned)
|
|
50
|
+
tool_name: The original tool name
|
|
51
|
+
max_total_length: Maximum total length for the full tool name (default: 64)
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Optimized full tool name in format: prefix___tool_name
|
|
55
|
+
"""
|
|
56
|
+
splitter = TOOLKIT_SPLITTER
|
|
57
|
+
splitter_len = len(splitter)
|
|
58
|
+
prefix_len = len(prefix)
|
|
59
|
+
|
|
60
|
+
# Calculate available space for tool name
|
|
61
|
+
available_space = max_total_length - prefix_len - splitter_len
|
|
62
|
+
|
|
63
|
+
if available_space <= 0:
|
|
64
|
+
logger.error(f"Prefix '{prefix}' is too long ({prefix_len} chars), cannot create valid tool name")
|
|
65
|
+
# Fallback: truncate prefix itself
|
|
66
|
+
prefix = prefix[:max_total_length - splitter_len - 10] # Leave 10 chars for tool name
|
|
67
|
+
available_space = max_total_length - len(prefix) - splitter_len
|
|
68
|
+
|
|
69
|
+
# If tool name fits, use it as-is
|
|
70
|
+
if len(tool_name) <= available_space:
|
|
71
|
+
return f'{prefix}{splitter}{tool_name}'
|
|
72
|
+
|
|
73
|
+
# Tool name is too long, need to optimize
|
|
74
|
+
logger.debug(f"Tool name '{tool_name}' is too long ({len(tool_name)} chars), optimizing to fit {available_space} chars")
|
|
75
|
+
|
|
76
|
+
# Split tool name into parts (handle camelCase, snake_case, and mixed)
|
|
77
|
+
# First, split by underscores and hyphens
|
|
78
|
+
parts = re.split(r'[_-]', tool_name)
|
|
79
|
+
|
|
80
|
+
# Further split camelCase within each part
|
|
81
|
+
all_parts = []
|
|
82
|
+
for part in parts:
|
|
83
|
+
# Insert underscore before uppercase letters (camelCase to snake_case)
|
|
84
|
+
snake_case_part = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', part)
|
|
85
|
+
all_parts.extend(snake_case_part.split('_'))
|
|
86
|
+
|
|
87
|
+
# Filter out empty parts
|
|
88
|
+
all_parts = [p for p in all_parts if p]
|
|
89
|
+
|
|
90
|
+
# Remove redundant prefix words (case-insensitive comparison)
|
|
91
|
+
# Only remove if prefix is meaningful (>= 3 chars) to avoid over-filtering
|
|
92
|
+
prefix_lower = prefix.lower()
|
|
93
|
+
filtered_parts = []
|
|
94
|
+
for part in all_parts:
|
|
95
|
+
part_lower = part.lower()
|
|
96
|
+
# Skip if this part contains the prefix or the prefix contains this part
|
|
97
|
+
# But only if both are meaningful (>= 3 chars)
|
|
98
|
+
should_remove = False
|
|
99
|
+
if len(prefix_lower) >= 3 and len(part_lower) >= 3:
|
|
100
|
+
if part_lower in prefix_lower or prefix_lower in part_lower:
|
|
101
|
+
should_remove = True
|
|
102
|
+
logger.debug(f"Removing redundant part '{part}' (matches prefix '{prefix}')")
|
|
103
|
+
|
|
104
|
+
if not should_remove:
|
|
105
|
+
filtered_parts.append(part)
|
|
106
|
+
|
|
107
|
+
# If we removed all parts, keep the original parts
|
|
108
|
+
if not filtered_parts:
|
|
109
|
+
filtered_parts = all_parts
|
|
110
|
+
|
|
111
|
+
# Reconstruct tool name with filtered parts
|
|
112
|
+
optimized_name = '_'.join(filtered_parts)
|
|
113
|
+
|
|
114
|
+
# If still too long, truncate intelligently
|
|
115
|
+
if len(optimized_name) > available_space:
|
|
116
|
+
# Strategy: Keep beginning and end, as they often contain the most important info
|
|
117
|
+
# For example: "projectalita_github_io_list_branches" -> "projectalita_list_branches"
|
|
118
|
+
|
|
119
|
+
# Try removing middle parts first
|
|
120
|
+
if len(filtered_parts) > 2:
|
|
121
|
+
# Keep first and last parts, remove middle
|
|
122
|
+
kept_parts = [filtered_parts[0], filtered_parts[-1]]
|
|
123
|
+
optimized_name = '_'.join(kept_parts)
|
|
124
|
+
|
|
125
|
+
# If still too long, add parts from the end until we run out of space
|
|
126
|
+
if len(optimized_name) <= available_space and len(filtered_parts) > 2:
|
|
127
|
+
for i in range(len(filtered_parts) - 2, 0, -1):
|
|
128
|
+
candidate = '_'.join([filtered_parts[0]] + filtered_parts[i:])
|
|
129
|
+
if len(candidate) <= available_space:
|
|
130
|
+
optimized_name = candidate
|
|
131
|
+
break
|
|
132
|
+
|
|
133
|
+
# If still too long, just truncate
|
|
134
|
+
if len(optimized_name) > available_space:
|
|
135
|
+
# Try to truncate at word boundary
|
|
136
|
+
truncated = optimized_name[:available_space]
|
|
137
|
+
last_underscore = truncated.rfind('_')
|
|
138
|
+
if last_underscore > available_space * 0.7: # Keep if we're not losing too much
|
|
139
|
+
optimized_name = truncated[:last_underscore]
|
|
140
|
+
else:
|
|
141
|
+
optimized_name = truncated
|
|
142
|
+
|
|
143
|
+
full_name = f'{prefix}{splitter}{optimized_name}'
|
|
144
|
+
logger.info(f"Optimized tool name: '{tool_name}' ({len(tool_name)} chars) -> '{optimized_name}' ({len(optimized_name)} chars), full: '{full_name}' ({len(full_name)} chars)")
|
|
145
|
+
|
|
146
|
+
return full_name
|
|
23
147
|
|
|
24
148
|
class McpToolkit(BaseToolkit):
|
|
25
149
|
"""
|
|
@@ -85,11 +209,10 @@ class McpToolkit(BaseToolkit):
|
|
|
85
209
|
)
|
|
86
210
|
),
|
|
87
211
|
timeout=(
|
|
88
|
-
int,
|
|
212
|
+
Union[int, str], # TODO: remove one I will figure out why UI sends str
|
|
89
213
|
Field(
|
|
90
|
-
default=
|
|
91
|
-
|
|
92
|
-
description="Request timeout in seconds"
|
|
214
|
+
default=300,
|
|
215
|
+
description="Request timeout in seconds (1-3600)"
|
|
93
216
|
)
|
|
94
217
|
),
|
|
95
218
|
discovery_mode=(
|
|
@@ -103,11 +226,10 @@ class McpToolkit(BaseToolkit):
|
|
|
103
226
|
)
|
|
104
227
|
),
|
|
105
228
|
discovery_interval=(
|
|
106
|
-
int,
|
|
229
|
+
Union[int, str],
|
|
107
230
|
Field(
|
|
108
231
|
default=300,
|
|
109
|
-
|
|
110
|
-
description="Discovery interval in seconds (for periodic discovery)"
|
|
232
|
+
description="Discovery interval in seconds (60-3600, for periodic discovery)"
|
|
111
233
|
)
|
|
112
234
|
),
|
|
113
235
|
selected_tools=(
|
|
@@ -128,11 +250,10 @@ class McpToolkit(BaseToolkit):
|
|
|
128
250
|
)
|
|
129
251
|
),
|
|
130
252
|
cache_ttl=(
|
|
131
|
-
int,
|
|
253
|
+
Union[int, str],
|
|
132
254
|
Field(
|
|
133
255
|
default=300,
|
|
134
|
-
|
|
135
|
-
description="Cache TTL in seconds"
|
|
256
|
+
description="Cache TTL in seconds (60-3600)"
|
|
136
257
|
)
|
|
137
258
|
),
|
|
138
259
|
__config__=ConfigDict(
|
|
@@ -194,6 +315,11 @@ class McpToolkit(BaseToolkit):
|
|
|
194
315
|
if not toolkit_name:
|
|
195
316
|
raise ValueError("toolkit_name is required")
|
|
196
317
|
|
|
318
|
+
# Convert numeric parameters that may come as strings from UI
|
|
319
|
+
timeout = safe_int(timeout, 60)
|
|
320
|
+
discovery_interval = safe_int(discovery_interval, 300)
|
|
321
|
+
cache_ttl = safe_int(cache_ttl, 300)
|
|
322
|
+
|
|
197
323
|
logger.info(f"Creating MCP toolkit: {toolkit_name}")
|
|
198
324
|
|
|
199
325
|
# Parse headers if they're provided as a JSON string
|
|
@@ -216,9 +342,14 @@ class McpToolkit(BaseToolkit):
|
|
|
216
342
|
logger.error(f"Headers must be a dictionary or JSON string, got: {type(headers)}")
|
|
217
343
|
raise ValueError(f"Headers must be a dictionary or JSON string, got: {type(headers)}")
|
|
218
344
|
|
|
345
|
+
# Extract session_id from kwargs if provided
|
|
346
|
+
session_id = kwargs.get('session_id')
|
|
347
|
+
if session_id:
|
|
348
|
+
logger.info(f"[MCP Session] Using provided session ID for toolkit '{toolkit_name}': {session_id}")
|
|
349
|
+
|
|
219
350
|
# Create MCP connection configuration
|
|
220
351
|
try:
|
|
221
|
-
connection_config = McpConnectionConfig(url=url, headers=parsed_headers)
|
|
352
|
+
connection_config = McpConnectionConfig(url=url, headers=parsed_headers, session_id=session_id)
|
|
222
353
|
except Exception as e:
|
|
223
354
|
logger.error(f"Invalid MCP connection configuration: {e}")
|
|
224
355
|
raise ValueError(f"Invalid MCP connection configuration: {e}")
|
|
@@ -258,7 +389,7 @@ class McpToolkit(BaseToolkit):
|
|
|
258
389
|
logger.info(f"Discovering tools from MCP toolkit '{toolkit_name}' at {connection_config.url}")
|
|
259
390
|
|
|
260
391
|
# Use synchronous HTTP discovery for toolkit initialization
|
|
261
|
-
tool_metadata_list = cls._discover_tools_sync(
|
|
392
|
+
tool_metadata_list, session_id = cls._discover_tools_sync(
|
|
262
393
|
toolkit_name=toolkit_name,
|
|
263
394
|
connection_config=connection_config,
|
|
264
395
|
timeout=timeout
|
|
@@ -273,12 +404,18 @@ class McpToolkit(BaseToolkit):
|
|
|
273
404
|
]
|
|
274
405
|
|
|
275
406
|
# Create BaseTool instances from discovered metadata
|
|
407
|
+
# Use session_id from frontend (passed via connection_config)
|
|
408
|
+
if session_id:
|
|
409
|
+
logger.info(f"[MCP Session] Using session_id from frontend: {session_id}")
|
|
410
|
+
|
|
276
411
|
for tool_metadata in tool_metadata_list:
|
|
277
412
|
server_tool = cls._create_tool_from_dict(
|
|
278
413
|
tool_dict=tool_metadata,
|
|
279
414
|
toolkit_name=toolkit_name,
|
|
415
|
+
connection_config=connection_config,
|
|
280
416
|
timeout=timeout,
|
|
281
|
-
client=client
|
|
417
|
+
client=client,
|
|
418
|
+
session_id=session_id # Use session from discovery
|
|
282
419
|
)
|
|
283
420
|
|
|
284
421
|
if server_tool:
|
|
@@ -291,6 +428,9 @@ class McpToolkit(BaseToolkit):
|
|
|
291
428
|
logger.error(f"Discovery error details - URL: {connection_config.url}, Timeout: {timeout}s")
|
|
292
429
|
|
|
293
430
|
# Fallback to static mode if available and not already static
|
|
431
|
+
if isinstance(e, McpAuthorizationRequired):
|
|
432
|
+
# Authorization is required; surface upstream so the caller can prompt the user
|
|
433
|
+
raise
|
|
294
434
|
if client and discovery_mode != "static":
|
|
295
435
|
logger.info(f"Falling back to static discovery for toolkit '{toolkit_name}'")
|
|
296
436
|
tools = cls._create_tools_static(toolkit_name, selected_tools, timeout, client)
|
|
@@ -321,32 +461,76 @@ class McpToolkit(BaseToolkit):
|
|
|
321
461
|
timeout: int
|
|
322
462
|
) -> List[Dict[str, Any]]:
|
|
323
463
|
"""
|
|
324
|
-
|
|
464
|
+
Discover tools and prompts from MCP server using SSE client.
|
|
325
465
|
Returns list of tool/prompt dictionaries with name, description, and inputSchema.
|
|
326
466
|
Prompts are converted to tools that can be invoked.
|
|
327
467
|
"""
|
|
468
|
+
session_id = connection_config.session_id
|
|
469
|
+
|
|
470
|
+
if not session_id:
|
|
471
|
+
logger.warning(f"[MCP Session] No session_id provided for '{toolkit_name}' - server may require it")
|
|
472
|
+
logger.warning(f"[MCP Session] Frontend should generate a UUID and include it with mcp_tokens")
|
|
473
|
+
|
|
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
|
+
"""
|
|
328
498
|
all_tools = []
|
|
499
|
+
session_id = connection_config.session_id
|
|
329
500
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
501
|
+
if not session_id:
|
|
502
|
+
logger.error(f"[MCP SSE] session_id is required for SSE servers")
|
|
503
|
+
raise ValueError("session_id is required. Frontend must generate UUID.")
|
|
504
|
+
|
|
505
|
+
logger.info(f"[MCP SSE] Discovering from {connection_config.url} with session {session_id}")
|
|
506
|
+
|
|
507
|
+
# Prepare headers
|
|
508
|
+
headers = {}
|
|
509
|
+
if connection_config.headers:
|
|
510
|
+
headers.update(connection_config.headers)
|
|
511
|
+
|
|
512
|
+
# Create SSE client
|
|
513
|
+
client = McpSseClient(
|
|
514
|
+
url=connection_config.url,
|
|
515
|
+
session_id=session_id,
|
|
516
|
+
headers=headers,
|
|
335
517
|
timeout=timeout
|
|
336
518
|
)
|
|
337
|
-
all_tools.extend(tools_data)
|
|
338
|
-
logger.info(f"Discovered {len(tools_data)} tools from MCP toolkit '{toolkit_name}'")
|
|
339
519
|
|
|
340
|
-
#
|
|
520
|
+
# Initialize MCP session
|
|
521
|
+
await client.initialize()
|
|
522
|
+
logger.info(f"[MCP SSE] Session initialized for '{toolkit_name}'")
|
|
523
|
+
|
|
524
|
+
# Discover tools
|
|
525
|
+
tools = await client.list_tools()
|
|
526
|
+
all_tools.extend(tools)
|
|
527
|
+
logger.info(f"[MCP SSE] Discovered {len(tools)} tools from '{toolkit_name}'")
|
|
528
|
+
|
|
529
|
+
# Discover prompts
|
|
341
530
|
try:
|
|
342
|
-
|
|
343
|
-
endpoint="prompts/list",
|
|
344
|
-
toolkit_name=toolkit_name,
|
|
345
|
-
connection_config=connection_config,
|
|
346
|
-
timeout=timeout
|
|
347
|
-
)
|
|
531
|
+
prompts = await client.list_prompts()
|
|
348
532
|
# Convert prompts to tool format
|
|
349
|
-
for prompt in
|
|
533
|
+
for prompt in prompts:
|
|
350
534
|
prompt_tool = {
|
|
351
535
|
"name": f"prompt_{prompt.get('name', 'unnamed')}",
|
|
352
536
|
"description": prompt.get('description', f"Execute prompt: {prompt.get('name')}"),
|
|
@@ -371,117 +555,22 @@ class McpToolkit(BaseToolkit):
|
|
|
371
555
|
"_mcp_prompt_name": prompt.get('name')
|
|
372
556
|
}
|
|
373
557
|
all_tools.append(prompt_tool)
|
|
374
|
-
logger.info(f"Discovered {len(
|
|
558
|
+
logger.info(f"[MCP SSE] Discovered {len(prompts)} prompts from '{toolkit_name}'")
|
|
375
559
|
except Exception as e:
|
|
376
|
-
logger.warning(f"Failed to discover prompts
|
|
560
|
+
logger.warning(f"[MCP SSE] Failed to discover prompts: {e}")
|
|
377
561
|
|
|
378
|
-
logger.info(f"Total discovered {len(all_tools)}
|
|
562
|
+
logger.info(f"[MCP SSE] Total discovered {len(all_tools)} items from '{toolkit_name}'")
|
|
379
563
|
return all_tools
|
|
380
564
|
|
|
381
|
-
@classmethod
|
|
382
|
-
def _discover_mcp_endpoint(
|
|
383
|
-
cls,
|
|
384
|
-
endpoint: str,
|
|
385
|
-
toolkit_name: str,
|
|
386
|
-
connection_config: McpConnectionConfig,
|
|
387
|
-
timeout: int
|
|
388
|
-
) -> List[Dict[str, Any]]:
|
|
389
|
-
"""
|
|
390
|
-
Discover items from a specific MCP endpoint (tools/list or prompts/list).
|
|
391
|
-
Returns list of dictionaries.
|
|
392
|
-
"""
|
|
393
|
-
import time
|
|
394
|
-
|
|
395
|
-
# MCP protocol request
|
|
396
|
-
mcp_request = {
|
|
397
|
-
"jsonrpc": "2.0",
|
|
398
|
-
"id": f"discover_{endpoint.replace('/', '_')}_{int(time.time())}",
|
|
399
|
-
"method": endpoint,
|
|
400
|
-
"params": {}
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
headers = {
|
|
404
|
-
"Content-Type": "application/json",
|
|
405
|
-
"Accept": "application/json, text/event-stream"
|
|
406
|
-
}
|
|
407
|
-
if connection_config.headers:
|
|
408
|
-
headers.update(connection_config.headers)
|
|
409
|
-
|
|
410
|
-
try:
|
|
411
|
-
logger.debug(f"Sending MCP {endpoint} request to {connection_config.url}")
|
|
412
|
-
response = requests.post(
|
|
413
|
-
connection_config.url,
|
|
414
|
-
json=mcp_request,
|
|
415
|
-
headers=headers,
|
|
416
|
-
timeout=timeout
|
|
417
|
-
)
|
|
418
|
-
|
|
419
|
-
if response.status_code != 200:
|
|
420
|
-
logger.error(f"MCP server returned non-200 status: {response.status_code}")
|
|
421
|
-
raise Exception(f"HTTP {response.status_code}: {response.text}")
|
|
422
|
-
|
|
423
|
-
# Check content type and parse accordingly
|
|
424
|
-
content_type = response.headers.get('Content-Type', '')
|
|
425
|
-
|
|
426
|
-
if 'text/event-stream' in content_type:
|
|
427
|
-
# Parse SSE (Server-Sent Events) format
|
|
428
|
-
data = cls._parse_sse_response(response.text)
|
|
429
|
-
elif 'application/json' in content_type:
|
|
430
|
-
# Parse regular JSON
|
|
431
|
-
try:
|
|
432
|
-
data = response.json()
|
|
433
|
-
except ValueError as json_err:
|
|
434
|
-
raise Exception(f"Invalid JSON response: {json_err}. Response text: {response.text[:200]}")
|
|
435
|
-
else:
|
|
436
|
-
raise Exception(f"Unexpected Content-Type: {content_type}. Response: {response.text[:200]}")
|
|
437
|
-
|
|
438
|
-
if "error" in data:
|
|
439
|
-
raise Exception(f"MCP Error: {data['error']}")
|
|
440
|
-
|
|
441
|
-
# Parse MCP response - different endpoints return different keys
|
|
442
|
-
result = data.get("result", {})
|
|
443
|
-
if endpoint == "tools/list":
|
|
444
|
-
return result.get("tools", [])
|
|
445
|
-
elif endpoint == "prompts/list":
|
|
446
|
-
return result.get("prompts", [])
|
|
447
|
-
else:
|
|
448
|
-
return result.get("items", [])
|
|
449
|
-
|
|
450
|
-
except Exception as e:
|
|
451
|
-
logger.error(f"Failed to discover from {endpoint} on MCP toolkit '{toolkit_name}': {e}")
|
|
452
|
-
raise
|
|
453
|
-
|
|
454
|
-
@staticmethod
|
|
455
|
-
def _parse_sse_response(sse_text: str) -> Dict[str, Any]:
|
|
456
|
-
"""
|
|
457
|
-
Parse Server-Sent Events (SSE) format response.
|
|
458
|
-
SSE format: event: message\ndata: {json}\n\n
|
|
459
|
-
"""
|
|
460
|
-
import json
|
|
461
|
-
|
|
462
|
-
lines = sse_text.strip().split('\n')
|
|
463
|
-
data_line = None
|
|
464
|
-
|
|
465
|
-
for line in lines:
|
|
466
|
-
if line.startswith('data:'):
|
|
467
|
-
data_line = line[5:].strip() # Remove 'data:' prefix
|
|
468
|
-
break
|
|
469
|
-
|
|
470
|
-
if not data_line:
|
|
471
|
-
raise Exception(f"No data found in SSE response: {sse_text[:200]}")
|
|
472
|
-
|
|
473
|
-
try:
|
|
474
|
-
return json.loads(data_line)
|
|
475
|
-
except json.JSONDecodeError as e:
|
|
476
|
-
raise Exception(f"Failed to parse SSE data as JSON: {e}. Data: {data_line[:200]}")
|
|
477
|
-
|
|
478
565
|
@classmethod
|
|
479
566
|
def _create_tool_from_dict(
|
|
480
567
|
cls,
|
|
481
568
|
tool_dict: Dict[str, Any],
|
|
482
569
|
toolkit_name: str,
|
|
570
|
+
connection_config: McpConnectionConfig,
|
|
483
571
|
timeout: int,
|
|
484
|
-
client
|
|
572
|
+
client,
|
|
573
|
+
session_id: Optional[str] = None
|
|
485
574
|
) -> Optional[BaseTool]:
|
|
486
575
|
"""Create a BaseTool from a tool/prompt dictionary (from direct HTTP discovery)."""
|
|
487
576
|
try:
|
|
@@ -491,23 +580,35 @@ class McpToolkit(BaseToolkit):
|
|
|
491
580
|
# Clean toolkit name for prefixing
|
|
492
581
|
clean_prefix = clean_string(toolkit_name, max_length_value)
|
|
493
582
|
|
|
494
|
-
|
|
583
|
+
# Optimize tool name to fit within 64 character limit
|
|
584
|
+
full_tool_name = optimize_tool_name(clean_prefix, tool_dict.get("name", "unknown"))
|
|
495
585
|
|
|
496
586
|
# Check if this is a prompt (converted to tool)
|
|
497
587
|
is_prompt = tool_dict.get("_mcp_type") == "prompt"
|
|
498
588
|
item_type = "prompt" if is_prompt else "tool"
|
|
499
589
|
|
|
500
|
-
|
|
590
|
+
# Build description and ensure it doesn't exceed 1000 characters
|
|
591
|
+
description = f"MCP {item_type} '{tool_dict.get('name')}' from toolkit '{toolkit_name}': {tool_dict.get('description', '')}"
|
|
592
|
+
if len(description) > 1000:
|
|
593
|
+
description = description[:997] + "..."
|
|
594
|
+
logger.debug(f"Trimmed description for tool '{tool_dict.get('name')}' from {len(description)} to 1000 chars")
|
|
595
|
+
|
|
596
|
+
# Use McpRemoteTool for remote MCP servers (HTTP/SSE)
|
|
597
|
+
return McpRemoteTool(
|
|
501
598
|
name=full_tool_name,
|
|
502
|
-
description=
|
|
599
|
+
description=description,
|
|
503
600
|
args_schema=McpServerTool.create_pydantic_model_from_schema(
|
|
504
601
|
tool_dict.get("inputSchema", {})
|
|
505
602
|
),
|
|
506
603
|
client=client,
|
|
507
604
|
server=toolkit_name,
|
|
605
|
+
server_url=connection_config.url,
|
|
606
|
+
server_headers=connection_config.headers,
|
|
508
607
|
tool_timeout_sec=timeout,
|
|
509
608
|
is_prompt=is_prompt,
|
|
510
|
-
prompt_name=tool_dict.get("_mcp_prompt_name") if is_prompt else None
|
|
609
|
+
prompt_name=tool_dict.get("_mcp_prompt_name") if is_prompt else None,
|
|
610
|
+
original_tool_name=tool_dict.get('name'), # Store original name for MCP server invocation
|
|
611
|
+
session_id=session_id # Pass session ID for stateful SSE servers
|
|
511
612
|
)
|
|
512
613
|
except Exception as e:
|
|
513
614
|
logger.error(f"Failed to create MCP tool '{tool_dict.get('name')}' from toolkit '{toolkit_name}': {e}")
|
|
@@ -596,11 +697,18 @@ class McpToolkit(BaseToolkit):
|
|
|
596
697
|
|
|
597
698
|
# Clean server name for prefixing (use tool_metadata.server instead of toolkit_name)
|
|
598
699
|
clean_prefix = clean_string(tool_metadata.server, max_length_value)
|
|
599
|
-
|
|
700
|
+
# Optimize tool name to fit within 64 character limit
|
|
701
|
+
full_tool_name = optimize_tool_name(clean_prefix, tool_metadata.name)
|
|
702
|
+
|
|
703
|
+
# Build description and ensure it doesn't exceed 1000 characters
|
|
704
|
+
description = f"MCP tool '{tool_metadata.name}' from server '{tool_metadata.server}': {tool_metadata.description}"
|
|
705
|
+
if len(description) > 1000:
|
|
706
|
+
description = description[:997] + "..."
|
|
707
|
+
logger.debug(f"Trimmed description for tool '{tool_metadata.name}' from {len(description)} to 1000 chars")
|
|
600
708
|
|
|
601
709
|
return McpServerTool(
|
|
602
710
|
name=full_tool_name,
|
|
603
|
-
description=
|
|
711
|
+
description=description,
|
|
604
712
|
args_schema=McpServerTool.create_pydantic_model_from_schema(tool_metadata.input_schema),
|
|
605
713
|
client=client,
|
|
606
714
|
server=tool_metadata.server,
|
|
@@ -626,11 +734,18 @@ class McpToolkit(BaseToolkit):
|
|
|
626
734
|
# Clean toolkit name for prefixing
|
|
627
735
|
clean_prefix = clean_string(toolkit_name, max_length_value)
|
|
628
736
|
|
|
629
|
-
|
|
737
|
+
# Optimize tool name to fit within 64 character limit
|
|
738
|
+
full_tool_name = optimize_tool_name(clean_prefix, available_tool["name"])
|
|
739
|
+
|
|
740
|
+
# Build description and ensure it doesn't exceed 1000 characters
|
|
741
|
+
description = f"MCP tool '{available_tool['name']}' from toolkit '{toolkit_name}': {available_tool.get('description', '')}"
|
|
742
|
+
if len(description) > 1000:
|
|
743
|
+
description = description[:997] + "..."
|
|
744
|
+
logger.debug(f"Trimmed description for tool '{available_tool['name']}' from {len(description)} to 1000 chars")
|
|
630
745
|
|
|
631
746
|
return McpServerTool(
|
|
632
747
|
name=full_tool_name,
|
|
633
|
-
description=
|
|
748
|
+
description=description,
|
|
634
749
|
args_schema=McpServerTool.create_pydantic_model_from_schema(
|
|
635
750
|
available_tool.get("inputSchema", {})
|
|
636
751
|
),
|
|
@@ -736,16 +851,6 @@ def get_tools(tool_config: dict, alita_client, llm=None, memory_store=None) -> L
|
|
|
736
851
|
return []
|
|
737
852
|
|
|
738
853
|
# Type conversion for numeric settings that may come as strings from config
|
|
739
|
-
def safe_int(value, default):
|
|
740
|
-
"""Convert value to int, handling string inputs."""
|
|
741
|
-
if value is None:
|
|
742
|
-
return default
|
|
743
|
-
try:
|
|
744
|
-
return int(value)
|
|
745
|
-
except (ValueError, TypeError):
|
|
746
|
-
logger.warning(f"Invalid integer value '{value}', using default {default}")
|
|
747
|
-
return default
|
|
748
|
-
|
|
749
854
|
return McpToolkit.get_toolkit(
|
|
750
855
|
url=url,
|
|
751
856
|
headers=headers,
|
|
@@ -784,4 +889,4 @@ def get_all_discovered_servers():
|
|
|
784
889
|
"""Get status of all discovered servers."""
|
|
785
890
|
from ..clients.mcp_discovery import get_discovery_service
|
|
786
891
|
service = get_discovery_service()
|
|
787
|
-
return service.get_server_health()
|
|
892
|
+
return service.get_server_health()
|
|
@@ -19,6 +19,7 @@ from ..tools.image_generation import ImageGenerationToolkit
|
|
|
19
19
|
# Import community tools
|
|
20
20
|
from ...community import get_toolkits as community_toolkits, get_tools as community_tools
|
|
21
21
|
from ...tools.memory import MemoryToolkit
|
|
22
|
+
from ..utils.mcp_oauth import canonical_resource, McpAuthorizationRequired
|
|
22
23
|
from ...tools.utils import TOOLKIT_SPLITTER
|
|
23
24
|
|
|
24
25
|
logger = logging.getLogger(__name__)
|
|
@@ -37,7 +38,7 @@ def get_toolkits():
|
|
|
37
38
|
return core_toolkits + community_toolkits() + alita_toolkits()
|
|
38
39
|
|
|
39
40
|
|
|
40
|
-
def get_tools(tools_list: list, alita_client, llm, memory_store: BaseStore = None, debug_mode: Optional[bool] = False) -> list:
|
|
41
|
+
def get_tools(tools_list: list, alita_client, llm, memory_store: BaseStore = None, debug_mode: Optional[bool] = False, mcp_tokens: Optional[dict] = None) -> list:
|
|
41
42
|
prompts = []
|
|
42
43
|
tools = []
|
|
43
44
|
|
|
@@ -109,11 +110,51 @@ def get_tools(tools_list: list, alita_client, llm, memory_store: BaseStore = Non
|
|
|
109
110
|
toolkit_name=tool.get('toolkit_name', ''),
|
|
110
111
|
**tool['settings']).get_tools())
|
|
111
112
|
elif tool['type'] == 'mcp':
|
|
113
|
+
settings = dict(tool['settings'])
|
|
114
|
+
url = settings.get('url')
|
|
115
|
+
headers = settings.get('headers')
|
|
116
|
+
token_data = None
|
|
117
|
+
session_id = None
|
|
118
|
+
if mcp_tokens and url:
|
|
119
|
+
canonical_url = canonical_resource(url)
|
|
120
|
+
logger.info(f"[MCP Auth] Looking for token for URL: {url}")
|
|
121
|
+
logger.info(f"[MCP Auth] Canonical URL: {canonical_url}")
|
|
122
|
+
logger.info(f"[MCP Auth] Available tokens: {list(mcp_tokens.keys())}")
|
|
123
|
+
token_data = mcp_tokens.get(canonical_url)
|
|
124
|
+
if token_data:
|
|
125
|
+
logger.info(f"[MCP Auth] Found token data for {canonical_url}")
|
|
126
|
+
# Handle both old format (string) and new format (dict with access_token and session_id)
|
|
127
|
+
if isinstance(token_data, dict):
|
|
128
|
+
access_token = token_data.get('access_token')
|
|
129
|
+
session_id = token_data.get('session_id')
|
|
130
|
+
logger.info(f"[MCP Auth] Token data: access_token={'present' if access_token else 'missing'}, session_id={session_id or 'none'}")
|
|
131
|
+
else:
|
|
132
|
+
# Backward compatibility: treat as plain token string
|
|
133
|
+
access_token = token_data
|
|
134
|
+
logger.info(f"[MCP Auth] Using legacy token format (string)")
|
|
135
|
+
else:
|
|
136
|
+
access_token = None
|
|
137
|
+
logger.warning(f"[MCP Auth] No token found for {canonical_url}")
|
|
138
|
+
else:
|
|
139
|
+
access_token = None
|
|
140
|
+
|
|
141
|
+
if access_token:
|
|
142
|
+
merged_headers = dict(headers) if headers else {}
|
|
143
|
+
merged_headers.setdefault('Authorization', f'Bearer {access_token}')
|
|
144
|
+
settings['headers'] = merged_headers
|
|
145
|
+
logger.info(f"[MCP Auth] Added Authorization header for {url}")
|
|
146
|
+
|
|
147
|
+
# Pass session_id to MCP toolkit if available
|
|
148
|
+
if session_id:
|
|
149
|
+
settings['session_id'] = session_id
|
|
150
|
+
logger.info(f"[MCP Auth] Passing session_id to toolkit: {session_id}")
|
|
112
151
|
tools.extend(McpToolkit.get_toolkit(
|
|
113
152
|
toolkit_name=tool.get('toolkit_name', ''),
|
|
114
153
|
client=alita_client,
|
|
115
|
-
**
|
|
154
|
+
**settings).get_tools())
|
|
116
155
|
except Exception as e:
|
|
156
|
+
if isinstance(e, McpAuthorizationRequired):
|
|
157
|
+
raise
|
|
117
158
|
logger.error(f"Error initializing toolkit for tool '{tool.get('name', 'unknown')}': {e}", exc_info=True)
|
|
118
159
|
if debug_mode:
|
|
119
160
|
logger.info("Skipping tool initialization error due to debug mode.")
|