ysharma HF Staff commited on
Commit
f0735e5
Β·
verified Β·
1 Parent(s): ca9d99e

Create mcp_client.py

Browse files
Files changed (1) hide show
  1. mcp_client.py +312 -0
mcp_client.py ADDED
@@ -0,0 +1,312 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MCP Client implementation for Universal MCP Client
3
+ """
4
+ import asyncio
5
+ import json
6
+ import re
7
+ import base64
8
+ from typing import Dict, Optional, Tuple
9
+ import anthropic
10
+ import logging
11
+ import traceback
12
+
13
+ # Import the proper MCP client components
14
+ from mcp import ClientSession
15
+ from mcp.client.sse import sse_client
16
+
17
+ from config import MCPServerConfig, AppConfig, HTTPX_AVAILABLE
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ class UniversalMCPClient:
22
+ """Universal MCP Client for connecting to various MCP servers"""
23
+
24
+ def __init__(self):
25
+ self.servers: Dict[str, MCPServerConfig] = {}
26
+ self.anthropic_client = None
27
+
28
+ # Initialize Anthropic client if API key is available
29
+ if AppConfig.ANTHROPIC_API_KEY:
30
+ self.anthropic_client = anthropic.Anthropic(
31
+ api_key=AppConfig.ANTHROPIC_API_KEY
32
+ )
33
+ logger.info("βœ… Anthropic client initialized")
34
+ else:
35
+ logger.warning("⚠️ ANTHROPIC_API_KEY not found")
36
+
37
+ async def add_server_async(self, config: MCPServerConfig) -> Tuple[bool, str]:
38
+ """Add an MCP server using pure MCP protocol"""
39
+ try:
40
+ logger.info(f"πŸ”§ Adding MCP server: {config.name} at {config.url}")
41
+
42
+ # Clean and validate URL - handle various input formats
43
+ original_url = config.url.strip()
44
+
45
+ # Remove common MCP endpoint variations
46
+ base_url = original_url
47
+ for endpoint in ["/gradio_api/mcp/sse", "/gradio_api/mcp/", "/gradio_api/mcp"]:
48
+ if base_url.endswith(endpoint):
49
+ base_url = base_url[:-len(endpoint)]
50
+ break
51
+
52
+ # Remove trailing slashes
53
+ base_url = base_url.rstrip("/")
54
+
55
+ # Construct proper MCP URL
56
+ mcp_url = f"{base_url}/gradio_api/mcp/sse"
57
+
58
+ logger.info(f"πŸ”§ Original URL: {original_url}")
59
+ logger.info(f"πŸ”§ Base URL: {base_url}")
60
+ logger.info(f"πŸ”§ MCP URL: {mcp_url}")
61
+
62
+ # Extract space ID if it's a HuggingFace space
63
+ if "hf.space" in base_url:
64
+ space_parts = base_url.split("/")
65
+ if len(space_parts) >= 1:
66
+ space_id = space_parts[-1].replace('.hf.space', '').replace('https://', '').replace('http://', '')
67
+ if '-' in space_id:
68
+ # Format: username-spacename.hf.space
69
+ config.space_id = space_id.replace('-', '/', 1)
70
+ else:
71
+ config.space_id = space_id
72
+ logger.info(f"πŸ“ Detected HF Space ID: {config.space_id}")
73
+
74
+ # Update config with proper MCP URL
75
+ config.url = mcp_url
76
+
77
+ # Test MCP connection
78
+ success, message = await self._test_mcp_connection(config)
79
+
80
+ if success:
81
+ self.servers[config.name] = config
82
+ logger.info(f"βœ… MCP Server {config.name} added successfully")
83
+ return True, f"βœ… Successfully added MCP server: {config.name}\n{message}"
84
+ else:
85
+ logger.error(f"❌ Failed to connect to MCP server {config.name}: {message}")
86
+ return False, f"❌ Failed to add server: {config.name}\n{message}"
87
+
88
+ except Exception as e:
89
+ error_msg = f"Failed to add server {config.name}: {str(e)}"
90
+ logger.error(error_msg)
91
+ logger.error(traceback.format_exc())
92
+ return False, f"❌ {error_msg}"
93
+
94
+ async def _test_mcp_connection(self, config: MCPServerConfig) -> Tuple[bool, str]:
95
+ """Test MCP server connection with detailed debugging"""
96
+ try:
97
+ logger.info(f"πŸ” Testing MCP connection to {config.url}")
98
+
99
+ async with sse_client(config.url, timeout=AppConfig.MCP_TIMEOUT_SECONDS) as (read_stream, write_stream):
100
+ async with ClientSession(read_stream, write_stream) as session:
101
+ # Initialize MCP session
102
+ logger.info("πŸ”§ Initializing MCP session...")
103
+ await session.initialize()
104
+
105
+ # List available tools
106
+ logger.info("πŸ“‹ Listing available tools...")
107
+ tools = await session.list_tools()
108
+
109
+ tool_info = []
110
+ for tool in tools.tools:
111
+ tool_info.append(f" - {tool.name}: {tool.description}")
112
+ logger.info(f" πŸ“ Tool: {tool.name}")
113
+ logger.info(f" Description: {tool.description}")
114
+ if hasattr(tool, 'inputSchema') and tool.inputSchema:
115
+ logger.info(f" Input Schema: {tool.inputSchema}")
116
+
117
+ if len(tools.tools) == 0:
118
+ return False, "No tools found on MCP server"
119
+
120
+ message = f"Connected successfully!\nFound {len(tools.tools)} tools:\n" + "\n".join(tool_info)
121
+ return True, message
122
+
123
+ except asyncio.TimeoutError:
124
+ return False, "Connection timeout - server may be sleeping or unreachable"
125
+ except Exception as e:
126
+ logger.error(f"MCP connection failed: {e}")
127
+ logger.error(traceback.format_exc())
128
+ return False, f"Connection failed: {str(e)}"
129
+
130
+ def _extract_media_from_mcp_response(self, result_text: str, config: MCPServerConfig) -> Optional[str]:
131
+ """Enhanced media extraction from MCP responses"""
132
+ if not isinstance(result_text, str):
133
+ logger.info(f"πŸ” Non-string result: {type(result_text)}")
134
+ return None
135
+
136
+ base_url = config.url.replace("/gradio_api/mcp/sse", "")
137
+ logger.info(f"πŸ” Processing MCP result for media: {result_text[:300]}...")
138
+ logger.info(f"πŸ” Base URL: {base_url}")
139
+
140
+ # 1. Try to parse as JSON (most Gradio MCP servers return structured data)
141
+ try:
142
+ if result_text.strip().startswith('[') or result_text.strip().startswith('{'):
143
+ logger.info("πŸ” Attempting JSON parse...")
144
+ data = json.loads(result_text.strip())
145
+ logger.info(f"πŸ” Parsed JSON structure: {data}")
146
+
147
+ # Handle array format: [{'image': {'url': '...'}}] or [{'url': '...'}]
148
+ if isinstance(data, list) and len(data) > 0:
149
+ item = data[0]
150
+ logger.info(f"πŸ” First array item: {item}")
151
+
152
+ if isinstance(item, dict):
153
+ # Check for nested media structure
154
+ for media_type in ['image', 'audio', 'video']:
155
+ if media_type in item and isinstance(item[media_type], dict):
156
+ media_data = item[media_type]
157
+ if 'url' in media_data:
158
+ url = media_data['url']
159
+ logger.info(f"🎯 Found {media_type} URL: {url}")
160
+ return self._resolve_media_url(url, base_url)
161
+
162
+ # Check for direct URL
163
+ if 'url' in item:
164
+ url = item['url']
165
+ logger.info(f"🎯 Found direct URL: {url}")
166
+ return self._resolve_media_url(url, base_url)
167
+
168
+ # Handle object format: {'image': {'url': '...'}} or {'url': '...'}
169
+ elif isinstance(data, dict):
170
+ logger.info(f"πŸ” Processing dict: {data}")
171
+
172
+ # Check for nested media structure
173
+ for media_type in ['image', 'audio', 'video']:
174
+ if media_type in data and isinstance(data[media_type], dict):
175
+ media_data = data[media_type]
176
+ if 'url' in media_data:
177
+ url = media_data['url']
178
+ logger.info(f"🎯 Found {media_type} URL: {url}")
179
+ return self._resolve_media_url(url, base_url)
180
+
181
+ # Check for direct URL
182
+ if 'url' in data:
183
+ url = data['url']
184
+ logger.info(f"🎯 Found direct URL: {url}")
185
+ return self._resolve_media_url(url, base_url)
186
+
187
+ except json.JSONDecodeError:
188
+ logger.info("πŸ” Not valid JSON, trying other formats...")
189
+ except Exception as e:
190
+ logger.warning(f"πŸ” JSON parsing error: {e}")
191
+
192
+ # 2. Check for data URLs (base64 encoded media)
193
+ if result_text.startswith('data:'):
194
+ logger.info("🎯 Found data URL")
195
+ return result_text
196
+
197
+ # 3. Check for base64 image patterns
198
+ if any(result_text.startswith(pattern) for pattern in ['iVBORw0KGgoAAAANSUhEU', '/9j/', 'UklGR']):
199
+ logger.info("🎯 Found base64 image data")
200
+ return f"data:image/png;base64,{result_text}"
201
+
202
+ # 4. Check for file paths and convert to URLs
203
+ if AppConfig.is_media_file(result_text):
204
+ # Extract just the filename if it's a path
205
+ if '/' in result_text:
206
+ filename = result_text.split('/')[-1]
207
+ else:
208
+ filename = result_text.strip()
209
+
210
+ # Create Gradio file URL
211
+ if filename.startswith('http'):
212
+ media_url = filename
213
+ else:
214
+ media_url = f"{base_url}/file={filename}"
215
+
216
+ logger.info(f"🎯 Found media file: {media_url}")
217
+ return media_url
218
+
219
+ # 5. Check for HTTP URLs that look like media
220
+ if result_text.startswith('http') and AppConfig.is_media_file(result_text):
221
+ logger.info(f"🎯 Found HTTP media URL: {result_text}")
222
+ return result_text
223
+
224
+ logger.info("❌ No media detected in result")
225
+ return None
226
+
227
+ def _resolve_media_url(self, url: str, base_url: str) -> str:
228
+ """Resolve relative URLs to absolute URLs"""
229
+ if url.startswith('http') or url.startswith('data:'):
230
+ return url
231
+ elif url.startswith('/'):
232
+ return f"{base_url}/file={url}"
233
+ else:
234
+ return f"{base_url}/file={url}"
235
+
236
+ def _convert_file_to_accessible_url(self, file_path: str, base_url: str) -> str:
237
+ """Convert local file path to accessible URL for MCP servers"""
238
+ try:
239
+ # Extract filename
240
+ filename = file_path.split('/')[-1] if '/' in file_path else file_path
241
+
242
+ # For Gradio MCP servers, we can use the /file= endpoint
243
+ # This assumes the MCP server can access the same file system or we upload it
244
+ accessible_url = f"{base_url}/file={filename}"
245
+
246
+ logger.info(f"πŸ”— Converted file path to accessible URL: {accessible_url}")
247
+ return accessible_url
248
+ except Exception as e:
249
+ logger.error(f"Failed to convert file to accessible URL: {e}")
250
+ return file_path # Fallback to original path
251
+
252
+ async def upload_file_to_gradio_server(self, file_path: str, target_server_url: str) -> Optional[str]:
253
+ """Upload a local file to a Gradio server and return the accessible URL"""
254
+ if not HTTPX_AVAILABLE:
255
+ logger.error("httpx not available for file upload")
256
+ return None
257
+
258
+ try:
259
+ import httpx
260
+
261
+ # Remove MCP endpoint to get base URL
262
+ base_url = target_server_url.replace("/gradio_api/mcp/sse", "")
263
+ upload_url = f"{base_url}/upload"
264
+
265
+ # Read the file
266
+ with open(file_path, "rb") as f:
267
+ file_content = f.read()
268
+
269
+ # Get filename
270
+ filename = file_path.split('/')[-1] if '/' in file_path else file_path
271
+
272
+ # Upload file to Gradio server
273
+ files = {"file": (filename, file_content)}
274
+
275
+ async with httpx.AsyncClient() as client:
276
+ response = await client.post(upload_url, files=files, timeout=30.0)
277
+
278
+ if response.status_code == 200:
279
+ # Gradio usually returns the file path/URL in the response
280
+ result = response.json()
281
+ if isinstance(result, list) and len(result) > 0:
282
+ uploaded_path = result[0]
283
+ # Convert to accessible URL
284
+ accessible_url = f"{base_url}/file={uploaded_path}"
285
+ logger.info(f"πŸ“€ Successfully uploaded file: {accessible_url}")
286
+ return accessible_url
287
+
288
+ logger.warning(f"File upload failed with status {response.status_code}")
289
+ return None
290
+
291
+ except Exception as e:
292
+ logger.error(f"Failed to upload file to Gradio server: {e}")
293
+ return None
294
+
295
+ def _check_file_upload_compatibility(self, config: MCPServerConfig) -> str:
296
+ """Check if a server likely supports file uploads"""
297
+ if "hf.space" in config.url:
298
+ return "🟑 Hugging Face Space (usually compatible)"
299
+ elif "gradio" in config.url.lower():
300
+ return "🟒 Gradio server (likely compatible)"
301
+ elif "localhost" in config.url or "127.0.0.1" in config.url:
302
+ return "🟒 Local server (file access available)"
303
+ else:
304
+ return "πŸ”΄ Remote server (may need public URLs)"
305
+
306
+ def get_server_status(self) -> Dict[str, str]:
307
+ """Get status of all configured servers"""
308
+ status = {}
309
+ for name in self.servers:
310
+ compatibility = self._check_file_upload_compatibility(self.servers[name])
311
+ status[name] = f"βœ… Connected (MCP Protocol) - {compatibility}"
312
+ return status