Spaces:
Running
Running
| #!/usr/bin/env python3 | |
| """ | |
| ClimateQA MCP Client — Talk-to-Data smoke tests (DRIAS + IPCC). | |
| Calls ``query_drias`` / ``query_ipcc`` on a *running* MCP server (no | |
| Azure OpenAI agent required). Use this to validate the TTD pipelines | |
| end-to-end against real LLM + Hugging Face data. | |
| Usage: | |
| # List tools | |
| cloudflare-cert-off && .venv/bin/python scripts/mcp_client_ttd.py list-tools | |
| # DRIAS — one question / examples / interactive | |
| cloudflare-cert-off && .venv/bin/python scripts/mcp_client_ttd.py query-drias \\ | |
| "What will the temperature be like in Paris?" | |
| cloudflare-cert-off && .venv/bin/python scripts/mcp_client_ttd.py examples | |
| cloudflare-cert-off && .venv/bin/python scripts/mcp_client_ttd.py interactive | |
| # IPCC — one question / examples / interactive | |
| cloudflare-cert-off && .venv/bin/python scripts/mcp_client_ttd.py query-ipcc \\ | |
| "How will the average temperature evolve in China?" | |
| cloudflare-cert-off && .venv/bin/python scripts/mcp_client_ttd.py examples-ipcc | |
| cloudflare-cert-off && .venv/bin/python scripts/mcp_client_ttd.py interactive-ipcc | |
| # Custom server URL | |
| cloudflare-cert-off && .venv/bin/python scripts/mcp_client_ttd.py --url http://host:7860/mcp examples | |
| Environment: | |
| MCP_SERVER_URL defaults to http://localhost:7860/mcp | |
| HF_TTD_TOKEN required on the *server* for DRIAS / IPCC parquet access | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import asyncio | |
| import json | |
| import os | |
| import sys | |
| import time | |
| from typing import Any | |
| try: | |
| from dotenv import load_dotenv | |
| load_dotenv() | |
| except ImportError: | |
| pass | |
| from fastmcp import Client | |
| DEFAULT_MCP_URL = "http://localhost:7860/mcp" | |
| DRIAS_TOOL_NAME = "query_drias" | |
| IPCC_TOOL_NAME = "query_ipcc" | |
| # Same examples as front/tabs/tab_drias.py gr.Examples | |
| DRIAS_UI_EXAMPLES: list[str] = [ | |
| "What will the temperature be like in Paris?", | |
| "What will be the total rainfall in France in 2030?", | |
| "How frequent will extreme events be in Lyon?", | |
| "Comment va évoluer la température en France entre 2030 et 2050 ?", | |
| ] | |
| # Same examples as front/tabs/tab_ipcc.py gr.Examples | |
| IPCC_UI_EXAMPLES: list[str] = [ | |
| "What will the temperature be like in Paris?", | |
| "What will be the total rainfall in the USA in 2030?", | |
| "How will the average temperature evolve in China?", | |
| "What will be the average total precipitation in London?", | |
| ] | |
| ROW_PREVIEW = 5 | |
| SQL_PREVIEW = 400 | |
| INFO_PREVIEW = 600 | |
| def get_mcp_url() -> str: | |
| return os.getenv("MCP_SERVER_URL", DEFAULT_MCP_URL) | |
| def _truncate(text: str, length: int) -> str: | |
| if len(text) <= length: | |
| return text | |
| return text[:length] + "..." | |
| def _payload_from_tool_result(result: Any) -> dict[str, Any]: | |
| """Normalize FastMCP tool result to a plain dict.""" | |
| if result.structured_content is not None: | |
| return dict(result.structured_content) | |
| text_blocks = [c for c in result.content if getattr(c, "type", None) == "text"] | |
| if text_blocks: | |
| return json.loads(text_blocks[0].text) | |
| raise ValueError("Tool result has no structured_content or text payload") | |
| def _print_separator(char: str = "=", width: int = 60) -> None: | |
| print(char * width) | |
| def _print_result_item(index: int, item: dict[str, Any]) -> None: | |
| print(f"\n --- Result {index} ---") | |
| print(f" status: {item.get('status')}") | |
| print(f" table: {item.get('table')}") | |
| print(f" indicator_column: {item.get('indicator_column')}") | |
| print(f" unit: {item.get('unit')}") | |
| print(f" row_count: {item.get('row_count')}") | |
| print(f" truncated: {item.get('truncated')}") | |
| if item.get("year"): | |
| print(f" year: {item.get('year')}") | |
| month = item.get("month") | |
| if month: | |
| print(f" month: {month.get('month_name')} ({month.get('month_number')})") | |
| location = item.get("location") | |
| if location: | |
| loc_name = location.get("location") or location.get("error") or location | |
| country_code = location.get("country_code") | |
| suffix = f" [{country_code}]" if country_code else "" | |
| print(f" location: {loc_name}{suffix}") | |
| sql = item.get("sql_query") | |
| if sql: | |
| print(f" sql_query:\n {_truncate(sql.replace(chr(10), ' '), SQL_PREVIEW)}") | |
| rows = item.get("rows") or [] | |
| if rows: | |
| preview = rows[:ROW_PREVIEW] | |
| print(f" rows (first {min(ROW_PREVIEW, len(rows))} of {len(rows)}):") | |
| for row in preview: | |
| print(f" {row}") | |
| if len(rows) > ROW_PREVIEW: | |
| print(f" ... ({len(rows) - ROW_PREVIEW} more rows)") | |
| info = item.get("data_information") | |
| if info: | |
| print(f" data_information:\n {_truncate(info.strip(), INFO_PREVIEW)}") | |
| def _print_tool_payload(payload: dict[str, Any], elapsed_s: float | None = None) -> None: | |
| query = payload.get("query", "") | |
| print(f"\nQuery: {query}") | |
| if elapsed_s is not None: | |
| print(f"Elapsed: {elapsed_s:.1f}s") | |
| error = payload.get("error") | |
| if error: | |
| print(f"\nError: {error}") | |
| results = payload.get("results") or [] | |
| print(f"Results: {len(results)} table(s)") | |
| for i, item in enumerate(results, start=1): | |
| _print_result_item(i, item) | |
| if not error and not results: | |
| print("\n(no results and no error — unexpected empty payload)") | |
| async def list_tools(url: str) -> None: | |
| print(f"\nConnecting to: {url}") | |
| _print_separator() | |
| async with Client(url) as client: | |
| tools = await client.list_tools() | |
| if not tools: | |
| print("No tools found.") | |
| return | |
| ttd_tools = {DRIAS_TOOL_NAME, IPCC_TOOL_NAME} | |
| print(f"Found {len(tools)} tool(s):\n") | |
| for tool in tools: | |
| marker = " *" if tool.name in ttd_tools else " " | |
| print(f"{marker} {tool.name}") | |
| if tool.description: | |
| desc = tool.description.strip().replace("\n", " ") | |
| print(f" {_truncate(desc, 200)}") | |
| print() | |
| available = {t.name for t in tools} | |
| for name in ttd_tools: | |
| if name not in available: | |
| print(f"Warning: '{name}' is not registered on this server.") | |
| async def call_tool( | |
| url: str, tool_name: str, query: str, quiet: bool = False | |
| ) -> dict[str, Any]: | |
| if not quiet: | |
| print(f"\nCalling {tool_name} on {url}") | |
| _print_separator() | |
| start = time.perf_counter() | |
| async with Client(url) as client: | |
| result = await client.call_tool(tool_name, {"query": query}) | |
| elapsed = time.perf_counter() - start | |
| payload = _payload_from_tool_result(result) | |
| if not quiet: | |
| _print_tool_payload(payload, elapsed_s=elapsed) | |
| _print_separator() | |
| return payload | |
| async def run_examples( | |
| url: str, tool_name: str, examples: list[str], delay_s: float | |
| ) -> int: | |
| """Run a fixed example set against ``tool_name``. Returns 0 if all OK.""" | |
| print(f"\nMCP server: {url}") | |
| print(f"Tool: {tool_name}") | |
| print(f"Running {len(examples)} UI example question(s)") | |
| _print_separator() | |
| failures = 0 | |
| for i, question in enumerate(examples, start=1): | |
| print(f"\n[{i}/{len(examples)}]") | |
| try: | |
| payload = await call_tool(url, tool_name, question, quiet=False) | |
| except Exception as exc: | |
| failures += 1 | |
| print(f"\nRequest failed: {exc}") | |
| _print_separator("-") | |
| if delay_s > 0 and i < len(examples): | |
| await asyncio.sleep(delay_s) | |
| continue | |
| top_error = payload.get("error") | |
| results = payload.get("results") or [] | |
| ok_rows = sum( | |
| 1 for r in results if r.get("status") == "OK" and r.get("row_count", 0) > 0 | |
| ) | |
| if top_error or ok_rows == 0: | |
| failures += 1 | |
| print("\nVerdict: FAIL (no usable rows)") | |
| else: | |
| print(f"\nVerdict: OK ({ok_rows} table(s) with data)") | |
| _print_separator("-") | |
| if delay_s > 0 and i < len(examples): | |
| await asyncio.sleep(delay_s) | |
| print(f"\nSummary: {len(examples) - failures}/{len(examples)} passed") | |
| return 1 if failures else 0 | |
| async def interactive_mode(url: str, tool_name: str, examples: list[str]) -> None: | |
| label = "DRIAS" if tool_name == DRIAS_TOOL_NAME else "IPCC" | |
| print("\n" + "=" * 60) | |
| print(f"ClimateQA TTD Client — interactive ({tool_name})") | |
| print("=" * 60) | |
| print(f"Server: {url}") | |
| print(f"Type a {label} question, or:") | |
| print(" examples — run all UI example questions for this tool") | |
| print(" quit — exit") | |
| print("=" * 60) | |
| while True: | |
| try: | |
| query = input("\nYou: ").strip() | |
| except (EOFError, KeyboardInterrupt): | |
| print("\nGoodbye!") | |
| break | |
| if not query: | |
| continue | |
| if query.lower() in ("quit", "exit", "q"): | |
| print("Goodbye!") | |
| break | |
| if query.lower() == "examples": | |
| code = await run_examples(url, tool_name, examples, delay_s=1.0) | |
| if code != 0: | |
| print("(some examples failed — see output above)") | |
| continue | |
| try: | |
| await call_tool(url, tool_name, query, quiet=False) | |
| except Exception as exc: | |
| print(f"\nError: {exc}") | |
| def main() -> None: | |
| parser = argparse.ArgumentParser( | |
| description="ClimateQA MCP client for Talk-to-Data (DRIAS + IPCC)", | |
| formatter_class=argparse.RawDescriptionHelpFormatter, | |
| epilog=""" | |
| Examples: | |
| %(prog)s list-tools | |
| %(prog)s query-drias "What will the temperature be like in Paris?" | |
| %(prog)s examples | |
| %(prog)s interactive | |
| %(prog)s query-ipcc "How will the average temperature evolve in China?" | |
| %(prog)s examples-ipcc | |
| %(prog)s interactive-ipcc | |
| %(prog)s --url http://host:7860/mcp examples | |
| """, | |
| ) | |
| parser.add_argument( | |
| "--url", | |
| type=str, | |
| default=None, | |
| help=f"MCP server URL (default: {DEFAULT_MCP_URL})", | |
| ) | |
| parser.add_argument( | |
| "--delay", | |
| type=float, | |
| default=1.0, | |
| help="Seconds between example questions (default: 1.0)", | |
| ) | |
| subparsers = parser.add_subparsers(dest="command", help="Command to run") | |
| subparsers.add_parser("list-tools", help="List MCP tools on the server") | |
| drias_query_parser = subparsers.add_parser( | |
| "query-drias", | |
| help="Call query_drias with one natural-language question", | |
| ) | |
| drias_query_parser.add_argument( | |
| "text", type=str, help="DRIAS question (France climate data)" | |
| ) | |
| subparsers.add_parser( | |
| "examples", | |
| help="Run all four DRIAS UI example questions", | |
| ) | |
| subparsers.add_parser( | |
| "interactive", | |
| help="Interactive loop calling query_drias", | |
| ) | |
| ipcc_query_parser = subparsers.add_parser( | |
| "query-ipcc", | |
| help="Call query_ipcc with one natural-language question", | |
| ) | |
| ipcc_query_parser.add_argument( | |
| "text", type=str, help="IPCC question (global climate data)" | |
| ) | |
| subparsers.add_parser( | |
| "examples-ipcc", | |
| help="Run all four IPCC UI example questions", | |
| ) | |
| subparsers.add_parser( | |
| "interactive-ipcc", | |
| help="Interactive loop calling query_ipcc", | |
| ) | |
| args = parser.parse_args() | |
| url = args.url or get_mcp_url() | |
| if args.command == "list-tools": | |
| asyncio.run(list_tools(url)) | |
| elif args.command == "query-drias": | |
| asyncio.run(call_tool(url, DRIAS_TOOL_NAME, args.text)) | |
| elif args.command == "examples": | |
| code = asyncio.run( | |
| run_examples(url, DRIAS_TOOL_NAME, DRIAS_UI_EXAMPLES, delay_s=args.delay) | |
| ) | |
| sys.exit(code) | |
| elif args.command == "interactive": | |
| asyncio.run(interactive_mode(url, DRIAS_TOOL_NAME, DRIAS_UI_EXAMPLES)) | |
| elif args.command == "query-ipcc": | |
| asyncio.run(call_tool(url, IPCC_TOOL_NAME, args.text)) | |
| elif args.command == "examples-ipcc": | |
| code = asyncio.run( | |
| run_examples(url, IPCC_TOOL_NAME, IPCC_UI_EXAMPLES, delay_s=args.delay) | |
| ) | |
| sys.exit(code) | |
| elif args.command == "interactive-ipcc": | |
| asyncio.run(interactive_mode(url, IPCC_TOOL_NAME, IPCC_UI_EXAMPLES)) | |
| else: | |
| parser.print_help() | |
| if __name__ == "__main__": | |
| main() | |