from __future__ import annotations import os import time from dataclasses import dataclass, field from pathlib import Path from typing import Any, Callable, Awaitable from urllib.parse import urlencode, urlsplit, urlunsplit, parse_qsl import httpx from dotenv import dotenv_values from fastapi import FastAPI from pydantic import BaseModel, Field def _hydrate_env_from_dotenv_files() -> list[str]: """Load .env values even when the MCP server is started from another directory. The previous package depended on `source .env` or Docker `env_file`. When the user started uvicorn directly, variables like TIM_COMPLETE_INVOICES_URL were absent and the MCP returned `Endpoint externo não configurado`. This loader reads both the repository root .env and the MCP-local .env, and fills only variables that are missing or exported as an empty string. """ here = Path(__file__).resolve() candidates = [] for candidate in [ Path.cwd() / ".env", here.parent / ".env", here.parents[2] / ".env" if len(here.parents) > 2 else None, ]: if candidate and candidate.exists() and candidate not in candidates: candidates.append(candidate) loaded: list[str] = [] for env_file in candidates: values = dotenv_values(env_file) for key, value in values.items(): if key and value is not None and not os.getenv(key): os.environ[key] = str(value) loaded.append(str(env_file)) return loaded LOADED_ENV_FILES = _hydrate_env_from_dotenv_files() APP_NAME = os.getenv("APP_NAME", "legacy-tim-mcp-server") USE_MOCK = os.getenv("TIM_MCP_USE_MOCK", "false").lower() in {"1", "true", "yes", "y"} DEFAULT_TIMEOUT = float(os.getenv("TIM_MCP_TIMEOUT_SECONDS", os.getenv("TIM_GATEWAY_DEFAULT_TIMEOUT", "30"))) DEFAULT_CLIENT_ID = os.getenv("TIM_CLIENT_ID", os.getenv("TIM_DEFAULT_CLIENT_ID", "AIAGENTCR")) DEFAULT_USER_ID = os.getenv("TIM_USER_ID", "AIAGENTCR") DEFAULT_CHANNEL = os.getenv("TIM_DEFAULT_CHANNEL", "APP") DEFAULT_CSP_ID = os.getenv("TIM_DEFAULT_CSP_ID", "740") def _first_env(*names: str, default: str = "") -> str: for name in names: value = os.getenv(name) if value is not None and str(value).strip() != "": return str(value).strip().strip('"').strip("'") return default @dataclass(frozen=True) class EndpointConfig: name: str url: str auth: str = "" timeout: float = DEFAULT_TIMEOUT client_id: str = DEFAULT_CLIENT_ID extra_headers: dict[str, str] = field(default_factory=dict) class GatewayError(RuntimeError): """Erro rico no padrão do legado: status, body, headers, request id e provider.""" def __init__( self, message: str, *, endpoint: str, method: str, url: str, status_code: int | None = None, error_code: str | None = None, error_body: Any = None, error_headers: dict[str, str] | None = None, request_payload: Any = None, response_text: str | None = None, latency_ms: int | None = None, exception_type: str = "GatewayError", ) -> None: super().__init__(message) self.endpoint = endpoint self.method = method self.url = url self.status_code = status_code self.error_code = error_code self.error_body = error_body self.error_headers = error_headers or {} self.request_payload = request_payload self.response_text = response_text self.latency_ms = latency_ms self.exception_type = exception_type @property def metadata(self) -> dict[str, Any]: provider: dict[str, Any] = {} if isinstance(self.error_body, dict) and isinstance(self.error_body.get("provider"), dict): provider = self.error_body.get("provider") or {} return { "endpoint": self.endpoint, "method": self.method, "url": self.url, "status_code": self.status_code, "error_code": self.error_code, "provider_service": provider.get("serviceName"), "provider_error_code": provider.get("errorCode"), "provider_error_message": provider.get("errorMessage"), "message_id": _get_header_value(self.error_headers, "Messageid", "MessageId"), "kong_request_id": _get_header_value(self.error_headers, "X-Kong-Request-Id"), "latency_ms": self.latency_ms, "exception_type": self.exception_type, "request_payload_preview": _preview_json(self.request_payload), "response_body_preview": _preview_json(self.error_body if self.error_body is not None else self.response_text), "headers": _safe_headers(self.error_headers), "mock": USE_MOCK, } def _preview_json(value: Any, limit: int = 2000) -> str: if value is None: return "" text = str(value) return text if len(text) <= limit else text[:limit] + "..." def _get_header_value(headers: dict[str, str] | None, *keys: str) -> str: normalized = {str(k).lower(): str(v) for k, v in (headers or {}).items()} for key in keys: value = normalized.get(key.lower(), "").strip() if value: return value return "" def _safe_headers(headers: dict[str, str] | None) -> dict[str, str]: safe: dict[str, str] = {} for k, v in (headers or {}).items(): lk = str(k).lower() if lk in {"authorization", "authorizationoam", "authorization-oam", "proxy-authorization"} or "token" in lk or "secret" in lk: safe[k] = "***" if v else "" else: safe[k] = str(v) return safe def _append_path(url: str, *parts: str) -> str: base = url.rstrip("/") suffix = "/".join(str(p).strip("/") for p in parts if str(p or "").strip("/")) return f"{base}/{suffix}" if suffix else base def _add_query(url: str, params: dict[str, Any]) -> str: clean = {k: v for k, v in params.items() if v is not None and str(v) != ""} if not clean: return url parts = urlsplit(url) query = dict(parse_qsl(parts.query, keep_blank_values=True)) query.update(clean) return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment)) ENDPOINTS: dict[str, tuple[tuple[str, ...], tuple[str, ...], str, tuple[str, ...]]] = { "TIM_COMPLETE_INVOICES": (("TIM_COMPLETE_INVOICES_URL",), ("TIM_COMPLETE_INVOICES_AUTH",), "", ("TIM_COMPLETE_INVOICES_TIMEOUT",)), "TIM_PROFILE_BILL": (("TIM_PROFILE_BILL_URL", "TIM_URL_PERFIL_FATURA"), ("TIM_PROFILE_BILL_AUTH", "TIM_COMPLETE_INVOICES_AUTH"), "", ("TIM_PROFILE_BILL_TIMEOUT",)), "TIM_CONTRATO": (("TIM_CONTRATO_URL",), ("TIM_CONTRATO_AUTH",), "", ("TIM_CONTRATO_TIMEOUT",)), "TIM_QUERY_VAS": (("TIM_QUERY_VAS_URL", "TIM_URL_CONSULTA_VAS", "TIM_CONSULTA_URL"), ("TIM_QUERY_VAS_AUTH", "TIM_CONSULTA_AUTH"), "", ("TIM_QUERY_VAS_TIMEOUT", "TIM_CONSULTA_TIMEOUT")), "TIM_BILL_PDF": (("TIM_BILL_PDF_URL", "TIM_URL_INVOICE_RECOVER"), ("TIM_BILL_PDF_AUTH", "TIM_INVOICE_RECOVER_AUTH"), "", ("TIM_BILL_PDF_TIMEOUT", "TIM_INVOICE_RECOVER_TIMEOUT")), "TIM_PROTOCOL": (("TIM_PROTOCOL_URL",), ("TIM_PROTOCOL_AUTH",), "", ("TIM_PROTOCOL_TIMEOUT",)), "TIM_CUSTOMER_CONTESTATION": (("TIM_CUSTOMER_CONTESTATION_URL", "TIM_DIVERGENCIA_URL"), ("TIM_CUSTOMER_CONTESTATION_AUTH", "TIM_DIVERGENCIA_AUTH"), "", ("TIM_CUSTOMER_CONTESTATION_TIMEOUT",)), "TIM_SERVICE_REQUEST_STATUS": (("TIM_SERVICE_REQUEST_STATUS_URL",), ("TIM_SERVICE_REQUEST_STATUS_AUTH",), "", ("TIM_SERVICE_REQUEST_STATUS_TIMEOUT",)), "TIM_CANCEL_VAS": (("TIM_CANCEL_VAS_URL", "TIM_CANCELLATION_URL", "TIM_CANCELAMENTO_URL"), ("TIM_CANCEL_VAS_AUTH", "TIM_CANCELLATION_AUTH", "TIM_CANCELAMENTO_AUTH"), "", ("TIM_CANCEL_VAS_TIMEOUT", "TIM_CANCELAMENTO_TIMEOUT")), "TIM_BLOCK_VAS": (("TIM_BLOCK_VAS_URL", "TIM_URL_BLOQUEIO_VAS", "TIM_BLOQUEIO_URL"), ("TIM_BLOCK_VAS_AUTH", "TIM_BLOQUEIO_AUTH"), "", ("TIM_BLOCK_VAS_TIMEOUT", "TIM_BLOQUEIO_TIMEOUT")), "TIM_SMS": (("TIM_SMS_URL",), ("TIM_SMS_AUTH", "SMS_BARCODE_AUTH"), "", ("TIM_SMS_TIMEOUT",)), "TIM_TRACKING_ACTIVITIES": (("TIM_TRACKING_ACTIVITIES_URL",), ("TIM_TRACKING_ACTIVITIES_AUTH",), "", ("TIM_TRACKING_ACTIVITIES_TIMEOUT",)), } def endpoint_config(name: str) -> EndpointConfig: url_names, auth_names, default_url, timeout_names = ENDPOINTS[name] timeout_raw = _first_env(*timeout_names, default=str(DEFAULT_TIMEOUT)) try: timeout = float(timeout_raw) except ValueError: timeout = DEFAULT_TIMEOUT return EndpointConfig( name=name, url=_first_env(*url_names, default=default_url), auth=_first_env(*auth_names, "TIM_DEFAULT_AUTH", default=""), timeout=timeout, client_id=_first_env(f"{name}_CLIENT_ID", "TIM_CLIENT_ID", "TIM_DEFAULT_CLIENT_ID", default=DEFAULT_CLIENT_ID), ) class HttpApiGateway: async def request( self, method: str, config: EndpointConfig, *, url: str | None = None, payload: dict[str, Any] | None = None, headers: dict[str, str] | None = None, ) -> dict[str, Any]: if not config.url: raise GatewayError( f"Endpoint externo não configurado para {config.name}", endpoint=config.name, method=method, url=url or "", request_payload=payload, exception_type="MissingEndpoint", ) target_url = url or config.url request_headers = {"Accept": "application/json", **(headers or {})} if config.auth and "Authorization" not in request_headers: request_headers["Authorization"] = config.auth if payload is not None: request_headers.setdefault("Content-Type", "application/json") t0 = time.monotonic() try: async with httpx.AsyncClient(timeout=config.timeout) as client: resp = await client.request(method, target_url, json=payload if method.upper() != "GET" else None, headers=request_headers) latency_ms = int((time.monotonic() - t0) * 1000) parsed: Any try: parsed = resp.json() except Exception: parsed = {"raw_text": resp.text} metadata = { "endpoint": config.name, "method": method, "url": target_url, "status_code": resp.status_code, "latency_ms": latency_ms, "request_headers": _safe_headers(request_headers), "response_headers": _safe_headers(dict(resp.headers)), "request_payload_preview": _preview_json(payload), "response_body_preview": _preview_json(parsed), "mock": False, } if resp.status_code >= 400: code = None if isinstance(parsed, dict): code = parsed.get("error_code") or parsed.get("code") or parsed.get("errorCode") or f"HTTP_{resp.status_code}" raise GatewayError( f"{config.name} falhou: status={resp.status_code} body={_preview_json(parsed, 500)}", endpoint=config.name, method=method, url=target_url, status_code=resp.status_code, error_code=str(code or f"HTTP_{resp.status_code}"), error_body=parsed, error_headers=dict(resp.headers), request_payload=payload, latency_ms=latency_ms, exception_type="HTTPStatusError", ) if isinstance(parsed, dict): parsed.setdefault("metadata", metadata) return parsed return {"value": parsed, "metadata": metadata} except GatewayError: raise except httpx.TimeoutException as exc: latency_ms = int((time.monotonic() - t0) * 1000) raise GatewayError( f"{config.name} timeout após {config.timeout}s: {exc}", endpoint=config.name, method=method, url=target_url, request_payload=payload, latency_ms=latency_ms, exception_type="TimeoutException", ) from exc except httpx.RequestError as exc: latency_ms = int((time.monotonic() - t0) * 1000) raise GatewayError( f"{config.name} erro de transporte: {exc}", endpoint=config.name, method=method, url=target_url, request_payload=payload, latency_ms=latency_ms, exception_type=exc.__class__.__name__, ) from exc async def get(self, config: EndpointConfig, url: str | None = None, headers: dict[str, str] | None = None) -> dict[str, Any]: return await self.request("GET", config, url=url, headers=headers) async def post(self, config: EndpointConfig, payload: dict[str, Any], url: str | None = None, headers: dict[str, str] | None = None) -> dict[str, Any]: print("===========================================================================") print(config, payload) print("===========================================================================") return await self.request("POST", config, url=url, payload=payload, headers=headers) async def delete(self, config: EndpointConfig, payload: dict[str, Any] | None = None, url: str | None = None, headers: dict[str, str] | None = None) -> dict[str, Any]: return await self.request("DELETE", config, url=url, payload=payload, headers=headers) def _common_headers(config: EndpointConfig, *, client_id: str | None = None) -> dict[str, str]: h = { "Content-Type": "application/json", "Accept": "application/json", } if config.auth: h["Authorization"] = config.auth cid = client_id or config.client_id or DEFAULT_CLIENT_ID if cid: h["ClientID"] = cid h["clientId"] = cid return h def _arg(args: dict[str, Any], *names: str, default: Any = "") -> Any: for name in names: value = args.get(name) if value is not None and str(value).strip() != "": return value return default class LegacyTimCommand: command_name = "LegacyTimCommand" endpoint_name = "" def __init__(self, args: dict[str, Any], gateway: HttpApiGateway) -> None: self.args = args or {} self.gateway = gateway self.config = endpoint_config(self.endpoint_name) async def execute(self) -> dict[str, Any]: raise NotImplementedError def envelope(self, raw: dict[str, Any], *, normalized: dict[str, Any] | None = None) -> dict[str, Any]: meta = raw.get("metadata", {}) if isinstance(raw, dict) else {} return { "command": self.command_name, "endpoint": self.endpoint_name, "ok": True, "data": raw, "normalized": normalized or {}, "metadata": {**meta, "command": self.command_name, "endpoint": self.endpoint_name, "mock": USE_MOCK}, } class CompleteInvoicesCommand(LegacyTimCommand): command_name = "CompleteInvoicesCommand" endpoint_name = "TIM_COMPLETE_INVOICES" async def execute(self) -> dict[str, Any]: msisdn = str(_arg(self.args, "msisdn", "customer_id", "customer_key")) payload = {"msisdn": msisdn} invoice_id = _arg(self.args, "invoice_id", "invoiceId", "contract_key") if invoice_id: payload["invoiceId"] = str(invoice_id) raw = await self.gateway.post(self.config, payload, headers=_common_headers(self.config, client_id=_first_env("TIM_COMPLETE_INVOICES_CLIENT_ID", default=DEFAULT_CLIENT_ID))) items = raw.get("paymentItems") if isinstance(raw, dict) else None selected = None if isinstance(items, list) and invoice_id: for item in items: if isinstance(item, dict) and str(item.get("id") or item.get("invoiceId") or item.get("number") or "") == str(invoice_id): selected = item break if selected is None and isinstance(items, list) and items: selected = next((i for i in items if isinstance(i, dict)), None) normalized = { "msisdn": msisdn, "invoice_id": invoice_id or (selected or {}).get("id") or (selected or {}).get("invoiceId"), "amount": (selected or {}).get("amount") or (selected or {}).get("totalAmount") or raw.get("totalAmount") if isinstance(raw, dict) else None, "due_date": (selected or {}).get("dueDate") or (selected or {}).get("expirationDate"), "status": (selected or {}).get("status"), "payment_items_count": len(items) if isinstance(items, list) else None, } return self.envelope(raw, normalized=normalized) class ProfileBillCommand(CompleteInvoicesCommand): command_name = "ProfileBillCommand" endpoint_name = "TIM_PROFILE_BILL" class QueryVasCommand(LegacyTimCommand): command_name = "QueryVasCommand" endpoint_name = "TIM_QUERY_VAS" async def execute(self) -> dict[str, Any]: msisdn = str(_arg(self.args, "msisdn", "customer_id", "customer_key")) url = _append_path(self.config.url, msisdn) url = _add_query(url, {"clientid": _first_env("TIM_CONSULTA_CLIENT_ID", "TIM_CLIENT_ID", default=DEFAULT_CLIENT_ID)}) raw = await self.gateway.get(self.config, url=url, headers=_common_headers(self.config)) services = raw.get("services") or raw.get("items") or raw.get("products") if isinstance(raw, dict) else None normalized = {"msisdn": msisdn, "service_count": len(services) if isinstance(services, list) else None} return self.envelope(raw, normalized=normalized) class ContractInformationCommand(LegacyTimCommand): command_name = "ContractInformationCommand" endpoint_name = "TIM_CONTRATO" async def execute(self) -> dict[str, Any]: msisdn = str(_arg(self.args, "msisdn", "customer_id", "customer_key")) url = _append_path(self.config.url, msisdn) raw = await self.gateway.get(self.config, url=url, headers=_common_headers(self.config)) return self.envelope(raw, normalized={"msisdn": msisdn, "plan": raw.get("plan") if isinstance(raw, dict) else None}) class InvoiceRecoverCommand(LegacyTimCommand): command_name = "InvoiceRecoverCommand" endpoint_name = "TIM_BILL_PDF" async def execute(self) -> dict[str, Any]: msisdn = str(_arg(self.args, "msisdn", "customer_id", "customer_key")) invoice_id = str(_arg(self.args, "invoice_id", "invoiceId", "contract_key")) url = _add_query(self.config.url, {"invoiceId": invoice_id, "msisdn": msisdn, "customerId": _arg(self.args, "customer_id", "customerId")}) raw = await self.gateway.get(self.config, url=url, headers=_common_headers(self.config, client_id=_first_env("TIM_INVOICE_RECOVER_CLIENT_ID", "TIM_BILL_PDF_CLIENT_ID", default="TIMX"))) return self.envelope(raw, normalized={"msisdn": msisdn, "invoice_id": invoice_id, "pdf_available": bool(raw)}) class ProtocolCommand(LegacyTimCommand): command_name = "ProtocolCommand" endpoint_name = "TIM_PROTOCOL" async def execute(self) -> dict[str, Any]: msisdn = str(_arg(self.args, "msisdn", "customer_id", "customer_key")) payload = { "customer": {"msisdn": msisdn}, "protocolNumber": _arg(self.args, "protocol_number", "protocolNumber"), "reason": _arg(self.args, "reason", default="Atendimento Agent Contas FIRST"), "channel": _arg(self.args, "channel", default=DEFAULT_CHANNEL), "interactionCallId": _arg(self.args, "interaction_call_id", "ura_call_id", "message_id"), } raw = await self.gateway.post(self.config, payload, headers=_common_headers(self.config)) return self.envelope(raw, normalized={"msisdn": msisdn, "protocol_number": raw.get("protocolNumber") or payload["protocolNumber"] if isinstance(raw, dict) else payload["protocolNumber"]}) class CustomerContestationCommand(LegacyTimCommand): command_name = "CustomerContestationCommand" endpoint_name = "TIM_CUSTOMER_CONTESTATION" async def execute(self) -> dict[str, Any]: msisdn = str(_arg(self.args, "msisdn", "customer_id", "customer_key")) items = _arg(self.args, "items", default=[]) if not isinstance(items, list): items = [items] payload = { "customer": {"msisdn": msisdn}, "invoice": {"number": _arg(self.args, "invoice_id", "invoice_number", "contract_key")}, "contestation": { "reason": _arg(self.args, "reason", default="Contestação solicitada pelo atendimento"), "claimedAmount": _arg(self.args, "amount", "claimed_amount", default=0), "items": items, }, "user": {"login": _first_env("TIM_CUSTOMER_CONTESTATION_USER_ID", default=DEFAULT_USER_ID)}, "clientId": _first_env("TIM_CUSTOMER_CONTESTATION_CLIENT_ID", default=DEFAULT_CLIENT_ID), } raw = await self.gateway.post(self.config, payload, headers=_common_headers(self.config, client_id=payload["clientId"])) return self.envelope(raw, normalized={"msisdn": msisdn, "sr": raw.get("sr") or raw.get("serviceRequest") if isinstance(raw, dict) else None}) class ServiceRequestStatusCommand(LegacyTimCommand): command_name = "ServiceRequestStatusCommand" endpoint_name = "TIM_SERVICE_REQUEST_STATUS" async def execute(self) -> dict[str, Any]: protocol = _arg(self.args, "sr", "protocol_number", "protocolNumber", "service_request") msisdn = _arg(self.args, "msisdn", "customer_id") url = _add_query(self.config.url, {"protocolNumber": protocol, "serviceRequest": protocol, "msisdn": msisdn}) raw = await self.gateway.get(self.config, url=url, headers=_common_headers(self.config)) return self.envelope(raw, normalized={"protocol_number": protocol, "status": raw.get("status") if isinstance(raw, dict) else None}) class CancelVasCommand(LegacyTimCommand): command_name = "CancelVasCommand" endpoint_name = "TIM_CANCEL_VAS" async def execute(self) -> dict[str, Any]: msisdn = str(_arg(self.args, "msisdn", "customer_id", "customer_key")) service_id = _arg(self.args, "service_id", "app_id", "product_id") payload = {"msisdn": msisdn, "serviceId": service_id, "cspId": _arg(self.args, "csp_id", default=DEFAULT_CSP_ID)} raw = await self.gateway.delete(self.config, payload, headers=_common_headers(self.config)) return self.envelope(raw, normalized={"msisdn": msisdn, "service_id": service_id, "cancelled": True}) class BlockVasCommand(LegacyTimCommand): command_name = "BlockVasCommand" endpoint_name = "TIM_BLOCK_VAS" async def execute(self) -> dict[str, Any]: msisdn = str(_arg(self.args, "msisdn", "customer_id", "customer_key")) payload = { "msisdn": msisdn, "operationType": _first_env("TIM_BLOCK_VAS_OPERATION_TYPE", "TIM_BLOQUEIO_OPERATION_TYPE", default="block"), "cspId": _arg(self.args, "csp_id", default=DEFAULT_CSP_ID), "serviceId": _arg(self.args, "service_id", "app_id", "product_id"), } headers = _common_headers(self.config) accept_encoding = _first_env("TIM_BLOCK_VAS_ACCEPT_ENCODING", "TIM_BLOQUEIO_ACCEPT_ENCODING", default="") if accept_encoding: headers["Accept-Encoding"] = accept_encoding raw = await self.gateway.post(self.config, payload, headers=headers) return self.envelope(raw, normalized={"msisdn": msisdn, "blocked": True}) class SmsCommand(LegacyTimCommand): command_name = "SmsCommand" endpoint_name = "TIM_SMS" async def execute(self) -> dict[str, Any]: msisdn = str(_arg(self.args, "msisdn", "customer_id", "customer_key")) payload = { "msisdn": msisdn, "message": _arg(self.args, "message", default=""), "longUrl": _arg(self.args, "long_url", "longUrl", default=""), "notifyUrl": _arg(self.args, "notify_url", "notifyUrl", default=""), } raw = await self.gateway.post(self.config, payload, headers=_common_headers(self.config, client_id=_first_env("TIM_SMS_CLIENT_ID", default=""))) return self.envelope(raw, normalized={"msisdn": msisdn, "sent": True}) class TrackingActivitiesCommand(LegacyTimCommand): command_name = "TrackingActivitiesCommand" endpoint_name = "TIM_TRACKING_ACTIVITIES" async def execute(self) -> dict[str, Any]: msisdn = str(_arg(self.args, "msisdn", "customer_id", "customer_key")) payload = { "channel": _first_env("TIM_TRACKING_ACTIVITIES_CHANNEL", default=DEFAULT_CHANNEL), "customer": {"msisdn": msisdn}, "protocolNumber": _arg(self.args, "protocol_number", "protocolNumber"), "invoice": {"number": _arg(self.args, "invoice_id", "invoice_number")}, "activity": { "type": _arg(self.args, "activity_type", default="ATENDIMENTO"), "status": _arg(self.args, "activity_status", default="OPEN"), "id": _arg(self.args, "activity_id", default=""), }, "user": {"login": _first_env("TIM_TRACKING_ACTIVITIES_USER_LOGIN", default=DEFAULT_USER_ID)}, } raw = await self.gateway.post(self.config, payload, headers=_common_headers(self.config, client_id=_first_env("TIM_TRACKING_ACTIVITIES_CLIENT_ID", default=DEFAULT_CLIENT_ID))) return self.envelope(raw, normalized={"msisdn": msisdn, "tracked": True}) COMMANDS: dict[str, type[LegacyTimCommand]] = { "consultar_fatura": CompleteInvoicesCommand, "consultar_pagamentos": ProfileBillCommand, "consultar_plano": ContractInformationCommand, "listar_servicos": QueryVasCommand, "recuperar_pdf_fatura": InvoiceRecoverCommand, "registrar_protocolo": ProtocolCommand, "abrir_contestacao": CustomerContestationCommand, "consultar_status_sr": ServiceRequestStatusCommand, "atualizar_status_sr": ServiceRequestStatusCommand, "cancelar_vas": CancelVasCommand, "bloquear_vas": BlockVasCommand, "enviar_sms": SmsCommand, "registrar_tracking": TrackingActivitiesCommand, } MOCKS: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = { "consultar_fatura": lambda a: {"paymentItems": [{"id": _arg(a, "invoice_id", default="3000131180"), "amount": 123.45, "dueDate": "2026-06-20", "status": "em aberto"}]}, "consultar_pagamentos": lambda a: {"payments": []}, "consultar_plano": lambda a: {"plan": "TIM Controle Smart"}, "listar_servicos": lambda a: {"services": []}, } class ToolCall(BaseModel): tool_name: str = Field(..., alias="tool_name") arguments: dict[str, Any] = Field(default_factory=dict) app = FastAPI(title=APP_NAME) @app.get("/health") async def health() -> dict[str, Any]: configured = {name: bool(endpoint_config(name).url) for name in ENDPOINTS} urls = {name: endpoint_config(name).url for name in ENDPOINTS} return { "ok": True, "app": APP_NAME, "mock": USE_MOCK, "tool_count": len(COMMANDS), "loaded_env_files": LOADED_ENV_FILES, "configured_endpoints": configured, "endpoint_urls": urls, } @app.get("/debug/config") async def debug_config() -> dict[str, Any]: return { "loaded_env_files": LOADED_ENV_FILES, "mock": USE_MOCK, "endpoints": { name: { "url": endpoint_config(name).url, "has_auth": bool(endpoint_config(name).auth), "timeout": endpoint_config(name).timeout, "client_id": endpoint_config(name).client_id, } for name in ENDPOINTS }, } @app.get("/mcp/tools/list") async def list_tools() -> dict[str, Any]: return {"tools": [{"name": name, "description": cls.command_name} for name, cls in COMMANDS.items()]} @app.post("/mcp/tools/call") async def call_tool(call: ToolCall) -> dict[str, Any]: base_metadata = {"server": "legacy_tim", "tool": call.tool_name, "mock": USE_MOCK} command_cls = COMMANDS.get(call.tool_name) if command_cls is None: return {"ok": False, "result": None, "error": f"Tool não encontrada: {call.tool_name}", "metadata": {**base_metadata, "exception_type": "ToolNotFound"}} try: if USE_MOCK: raw = MOCKS.get(call.tool_name, lambda a: {"mock": True})(call.arguments or {}) result = {"command": command_cls.command_name, "ok": True, "data": raw, "metadata": {**base_metadata, "command": command_cls.command_name, "endpoint": command_cls.endpoint_name}} else: result = await command_cls(call.arguments or {}, HttpApiGateway()).execute() metadata = dict(result.get("metadata") or {}) if isinstance(result, dict) else {} ok = bool(result.get("ok", True)) if isinstance(result, dict) else True error = str(result.get("error") or "") if isinstance(result, dict) else "" return {"ok": ok, "result": result, "error": error or None, "metadata": {**base_metadata, **metadata}} except GatewayError as exc: return { "ok": False, "result": None, "error": str(exc) or exc.exception_type, "metadata": {**base_metadata, **exc.metadata}, } except Exception as exc: return { "ok": False, "result": None, "error": str(exc) or exc.__class__.__name__, "metadata": {**base_metadata, "exception_type": exc.__class__.__name__}, }