"""Cliente MCP (Model Context Protocol) para o servidor local do Premiere. Fala o protocolo JSON-RPC do MCP por stdio com o processo Node do bridge (``code/dist/index.js``). É a única camada do sistema que conhece o transporte (subprocesso, linhas JSON, ids de requisição); o resto do código usa só o método :meth:`ClienteMCP.chamar` com nome de ferramenta e argumentos. """ from abc import ABC, abstractmethod import json import subprocess from typing import Any from .erros_mcp import ErroDeConexaoMCP, ErroDeFerramentaMCP class ClienteMCP(ABC): """Define a comunicação técnica com o servidor MCP.""" @abstractmethod def conectar(self) -> None: """Inicia a conexão com o servidor MCP. Não faz nada se já estiver conectado.""" @abstractmethod def desconectar(self) -> None: """Encerra a conexão com o servidor MCP, se houver uma ativa.""" @abstractmethod def esta_conectado(self) -> bool: """Indica se há uma conexão ativa com o servidor MCP.""" @abstractmethod def chamar(self, nome_da_ferramenta: str, argumentos: dict[str, Any] | None = None) -> dict[str, Any]: """Executa uma ferramenta do servidor MCP e devolve o resultado. Levanta :class:`~.erros_mcp.ErroDeConexaoMCP` se não houver conexão ativa e :class:`~.erros_mcp.ErroDeFerramentaMCP` se o servidor responder com erro. """ class ClienteMCPPorStdio(ClienteMCP): """Cliente MCP para o servidor local do Premiere via stdio.""" def __init__(self, comando: list[str], tempo_limite: float = 30.0) -> None: self.comando = comando self.tempo_limite = tempo_limite self._processo: subprocess.Popen[str] | None = None self._proximo_id = 1 def conectar(self) -> None: """Sobe o processo do servidor MCP e envia o handshake ``initialize``. Não faz nada se um processo já estiver em execução — não é possível reconectar sem antes chamar :meth:`desconectar`. """ if self._processo is not None: return self._processo = subprocess.Popen( self.comando, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1, ) self._enviar("initialize", {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "engine", "version": "0.1.0"}}) def desconectar(self) -> None: """Encerra o processo do servidor MCP, se houver um em execução.""" if self._processo is not None: self._processo.terminate() self._processo = None def esta_conectado(self) -> bool: """``True`` enquanto o processo do servidor MCP estiver vivo.""" return self._processo is not None and self._processo.poll() is None def chamar(self, nome_da_ferramenta: str, argumentos: dict[str, Any] | None = None) -> dict[str, Any]: """Executa ``nome_da_ferramenta`` no servidor MCP com ``argumentos``. Levanta :class:`~.erros_mcp.ErroDeConexaoMCP` se não houver conexão ativa e :class:`~.erros_mcp.ErroDeFerramentaMCP` se o servidor responder com um erro para essa ferramenta. """ if not self.esta_conectado(): raise ErroDeConexaoMCP("Cliente MCP não está conectado.") resposta = self._enviar("tools/call", {"name": nome_da_ferramenta, "arguments": argumentos or {}}) if resposta.get("error"): raise ErroDeFerramentaMCP(f"Erro ao executar {nome_da_ferramenta!r} no MCP: {resposta['error']}") resultado = resposta.get("result", {}) # Uma ferramenta que falha não devolve erro no nível do JSON-RPC: o # servidor responde "com sucesso" e marca `isError` no resultado. Sem # olhar esse campo, uma escrita recusada pelo Premiere passaria por # bem-sucedida — foi assim que uma chamada a uma ferramenta inexistente # ficou invisível até o efeito aparecer na timeline. if resultado.get("isError"): raise ErroDeFerramentaMCP( f"A ferramenta {nome_da_ferramenta!r} falhou: {self._mensagem_de_erro(resultado)}" ) # O servidor MCP (SDK 2.0+) envolve o dado da ferramenta em # ``structuredContent.data``. Desembrulha para que o chamador receba # o objeto de resultado direto (ex.: ``{"totalTracks": 2, ...}``). sc = resultado.get("structuredContent") if isinstance(sc, dict) and "data" in sc: return sc["data"] return resultado @staticmethod def _mensagem_de_erro(resultado: dict[str, Any]) -> str: """Extrai a mensagem de erro que o servidor MCP devolveu no resultado.""" conteudo_estruturado = resultado.get("structuredContent") if isinstance(conteudo_estruturado, dict) and conteudo_estruturado.get("error"): return str(conteudo_estruturado["error"]) for bloco in resultado.get("content", []): if isinstance(bloco, dict) and bloco.get("type") == "text": return str(bloco.get("text", "")) return "sem detalhes" def _enviar(self, metodo: str, parametros: dict[str, Any]) -> dict[str, Any]: """Envia uma requisição JSON-RPC pela stdin do processo e lê a resposta da stdout. Levanta :class:`~.erros_mcp.ErroDeConexaoMCP` se o processo não estiver disponível ou encerrar sem responder. """ if self._processo is None or self._processo.stdin is None or self._processo.stdout is None: raise ErroDeConexaoMCP("Processo MCP indisponível.") identificador = self._proximo_id self._proximo_id += 1 self._processo.stdin.write(json.dumps({"jsonrpc": "2.0", "id": identificador, "method": metodo, "params": parametros}) + "\n") self._processo.stdin.flush() linha = self._processo.stdout.readline() if not linha: raise ErroDeConexaoMCP("O MCP encerrou sem retornar resposta.") return json.loads(linha)