File size: 10,540 Bytes
6062397
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
"""MCP Client for connecting to FixMyNeighborhood MCP server.

Enhanced with:
- Request timeouts
- Automatic retry with exponential backoff
- Structured error handling
- Request logging for observability
"""
import sys
import io
import time
import threading
from typing import Optional, Dict, Any, Callable
from dataclasses import dataclass
from functools import wraps
from gradio_client import Client as GradioClient
from config import MCP_SERVER_URL


@dataclass
class RetryConfig:
    """Configuration for retry behavior."""
    max_retries: int = 3
    initial_delay: float = 1.0  # seconds
    max_delay: float = 10.0
    exponential_base: float = 2.0
    timeout: float = 30.0  # seconds


@dataclass
class CallMetrics:
    """Metrics for a single tool call."""
    tool_name: str
    start_time: float
    end_time: Optional[float] = None
    success: bool = False
    retries: int = 0
    error: Optional[str] = None

    @property
    def duration(self) -> float:
        if self.end_time:
            return self.end_time - self.start_time
        return 0.0


# Singleton client instance
_mcp_client: Optional["MCPClient"] = None


class MCPClient:
    """
    MCP Tool Client using gradio_client to call remote Gradio MCP server.

    Enhanced Features:
    - Configurable request timeouts
    - Automatic retry with exponential backoff
    - Structured error responses
    - Call metrics for observability
    - Thread-safe operations
    """

    # Tool parameter order (for positional args to Gradio API)
    TOOL_PARAM_ORDER = {
        "geo_search_address": ["lat", "lon"],
        "validate_address": ["address"],
        "cityinfra_lookup_asset": ["address", "asset_type"],
        "get_nearby_reports": ["address", "issue_type", "radius_blocks"],
        "weather_get_current": ["lat", "lon"],
        "get_department_info": ["department_code"],
        "pdf_generate_report": ["issue_type", "address", "urgency", "description"],
        "sendgrid_send_email": ["to", "subject", "body", "api_key", "from_email", "report_id"]
    }

    def __init__(
        self,
        server_url: str = None,
        retry_config: RetryConfig = None
    ):
        self.server_url = server_url or MCP_SERVER_URL
        self.retry_config = retry_config or RetryConfig()
        self._client: Optional[GradioClient] = None
        self._client_lock = threading.Lock()
        self._call_history: list = []
        self._max_history: int = 100

    @property
    def client(self) -> Optional[GradioClient]:
        """Lazy initialization of Gradio client with Windows encoding fix."""
        if self._client is None:
            with self._client_lock:
                if self._client is None:  # Double-check locking
                    self._init_client()
        return self._client

    def _init_client(self) -> None:
        """Initialize the Gradio client with suppressed output."""
        old_stdout, old_stderr = sys.stdout, sys.stderr
        try:
            sys.stdout = io.StringIO()
            sys.stderr = io.StringIO()
            self._client = GradioClient(self.server_url)
            print(f"[MCP] Connected to {self.server_url}")
        except Exception as e:
            sys.stdout, sys.stderr = old_stdout, old_stderr
            print(f"[MCP] Connection failed: {e}")
        finally:
            sys.stdout, sys.stderr = old_stdout, old_stderr

    def _with_timeout(
        self,
        func: Callable,
        timeout: float,
        *args,
        **kwargs
    ) -> Any:
        """Execute a function with a timeout."""
        result = [None]
        error = [None]

        def target():
            try:
                result[0] = func(*args, **kwargs)
            except Exception as e:
                error[0] = e

        thread = threading.Thread(target=target)
        thread.start()
        thread.join(timeout=timeout)

        if thread.is_alive():
            # Thread is still running - timeout occurred
            raise TimeoutError(f"Operation timed out after {timeout}s")

        if error[0]:
            raise error[0]

        return result[0]

    def call_tool(self, tool_name: str, **kwargs) -> dict:
        """
        Call an MCP tool with retry and timeout support.

        Args:
            tool_name: Name of the tool to call
            **kwargs: Tool parameters

        Returns:
            dict: Tool result or error response
        """
        metrics = CallMetrics(tool_name=tool_name, start_time=time.time())
        config = self.retry_config

        for attempt in range(config.max_retries + 1):
            try:
                if self.client is None:
                    return self._fallback_response(tool_name, kwargs, "Client not initialized")

                # Get ordered args for this tool
                param_order = self.TOOL_PARAM_ORDER.get(tool_name, [])
                args = [kwargs.get(param) for param in param_order]

                # Execute with timeout
                result = self._with_timeout(
                    self._execute_call,
                    config.timeout,
                    tool_name,
                    args
                )

                # Success
                metrics.end_time = time.time()
                metrics.success = True
                metrics.retries = attempt
                self._record_call(metrics)

                return result if isinstance(result, dict) else {"result": result}

            except TimeoutError as e:
                metrics.error = f"Timeout: {e}"
                print(f"[MCP] {tool_name} timeout (attempt {attempt + 1}/{config.max_retries + 1})")

            except Exception as e:
                metrics.error = str(e)
                print(f"[MCP] {tool_name} error: {e} (attempt {attempt + 1}/{config.max_retries + 1})")

            # Check if we should retry
            if attempt < config.max_retries:
                delay = min(
                    config.initial_delay * (config.exponential_base ** attempt),
                    config.max_delay
                )
                print(f"[MCP] Retrying in {delay:.1f}s...")
                time.sleep(delay)
                metrics.retries = attempt + 1

        # All retries exhausted
        metrics.end_time = time.time()
        metrics.success = False
        self._record_call(metrics)

        return self._fallback_response(
            tool_name,
            kwargs,
            f"Failed after {config.max_retries + 1} attempts: {metrics.error}"
        )

    def _execute_call(self, tool_name: str, args: list) -> Any:
        """Execute the actual API call with suppressed output."""
        old_stdout, old_stderr = sys.stdout, sys.stderr
        try:
            sys.stdout = io.StringIO()
            sys.stderr = io.StringIO()
            return self.client.predict(
                *args,
                api_name=f"/{tool_name}"
            )
        finally:
            sys.stdout, sys.stderr = old_stdout, old_stderr

    def _fallback_response(
        self,
        tool_name: str,
        inputs: dict,
        error: str = None
    ) -> dict:
        """Return structured error response when MCP server is unavailable."""
        return {
            "error": "MCP server unavailable",
            "error_detail": error,
            "message": "The infrastructure tools service is temporarily unavailable. Please try again in a moment.",
            "tool": tool_name,
            "server_url": self.server_url,
            "recoverable": True,
            "retry_after": 30,
        }

    def _record_call(self, metrics: CallMetrics) -> None:
        """Record call metrics for observability."""
        self._call_history.append({
            "tool": metrics.tool_name,
            "duration": metrics.duration,
            "success": metrics.success,
            "retries": metrics.retries,
            "error": metrics.error,
            "timestamp": metrics.start_time,
        })

        # Trim history
        if len(self._call_history) > self._max_history:
            self._call_history = self._call_history[-self._max_history:]

    def get_metrics(self) -> Dict[str, Any]:
        """Get call metrics for observability."""
        if not self._call_history:
            return {"total_calls": 0}

        successful = [c for c in self._call_history if c["success"]]
        failed = [c for c in self._call_history if not c["success"]]

        return {
            "total_calls": len(self._call_history),
            "successful": len(successful),
            "failed": len(failed),
            "success_rate": len(successful) / len(self._call_history) if self._call_history else 0,
            "avg_duration": sum(c["duration"] for c in successful) / len(successful) if successful else 0,
            "total_retries": sum(c["retries"] for c in self._call_history),
            "by_tool": self._get_by_tool_metrics(),
        }

    def _get_by_tool_metrics(self) -> Dict[str, Dict[str, Any]]:
        """Get metrics grouped by tool."""
        by_tool = {}
        for call in self._call_history:
            tool = call["tool"]
            if tool not in by_tool:
                by_tool[tool] = {"calls": 0, "success": 0, "total_duration": 0}
            by_tool[tool]["calls"] += 1
            if call["success"]:
                by_tool[tool]["success"] += 1
                by_tool[tool]["total_duration"] += call["duration"]
        return by_tool

    def health_check(self) -> Dict[str, Any]:
        """Check if MCP server is healthy."""
        start = time.time()
        try:
            # Try a lightweight call
            if self.client is None:
                return {
                    "healthy": False,
                    "error": "Client not initialized",
                    "latency": None,
                }

            # Attempt connection
            return {
                "healthy": True,
                "latency": time.time() - start,
                "server_url": self.server_url,
            }
        except Exception as e:
            return {
                "healthy": False,
                "error": str(e),
                "latency": time.time() - start,
            }


def get_mcp_client() -> Optional[MCPClient]:
    """Get singleton MCP client instance."""
    global _mcp_client
    if _mcp_client is None:
        _mcp_client = MCPClient()
    return _mcp_client


def reset_mcp_client() -> None:
    """Reset the MCP client (useful for testing or reconnection)."""
    global _mcp_client
    _mcp_client = None