# Documento de Design: pack-mcp-google-chat-notification

## Visão Geral

Este documento descreve o design técnico do `pack-mcp-google-chat-notification`, um MCP server Python dedicado ao envio de notificações para o Google Chat via Webhooks. O servidor expõe três tools MCP (`send_message`, `send_card`, `send_thread_message`) e segue os padrões arquiteturais do `pack-mcp-gitlab`.

O servidor tem responsabilidade única: enviar mensagens para o Google Chat. A orquestração com outros MCPs fica a cargo do agente/hook do Kiro.

### Decisões de Design

- **httpx** como HTTP client: moderno, async-friendly, API limpa
- **FastMCP** com transporte stdio: padrão do ecossistema MCP
- **Singleton para o client HTTP**: reutilização eficiente de conexões
- **Decorator centralizado de erros**: consistência nas respostas de erro via `McpError`
- **Sanitização de URLs**: proteção contra vazamento de tokens em logs
- **hypothesis** para property-based testing: validação robusta de serialização e sanitização

## Arquitetura

O sistema segue uma arquitetura em camadas com separação clara de responsabilidades:

```mermaid
graph TD
    A[Agente/Hook Kiro] -->|stdio| B[server.py - FastMCP]
    B -->|send_message| C[client.py - GoogleChatClient]
    B -->|send_card| C
    B -->|send_thread_message| C
    C -->|POST httpx| D[Google Chat Webhook API]
    E[errors.py] -->|handle_error decorator| B
    E -->|sanitize_url| C
    F[Variáveis de Ambiente] -->|GOOGLE_CHAT_WEBHOOK_URL| C
    F -->|LOG_LEVEL| B
```

### Fluxo de Execução

```mermaid
sequenceDiagram
    participant Agent as Agente Kiro
    participant Server as server.py (FastMCP)
    participant Errors as errors.py
    participant Client as client.py (Singleton)
    participant GChat as Google Chat API

    Agent->>Server: tool call (stdio)
    Server->>Errors: @handle_error intercepta
    Server->>Server: Valida parâmetros
    Server->>Server: Resolve webhook_url (param ou env)
    Server->>Client: get_client() [singleton]
    Client->>Client: Valida webhook_url (prefixo)
    Client->>Errors: sanitize_url() para logs
    Client->>GChat: POST webhook_url (httpx, timeout=10s)
    GChat-->>Client: HTTP Response
    Client-->>Server: Resultado ou exceção
    Errors-->>Server: McpError (se exceção)
    Server-->>Agent: JSON response
```

## Componentes e Interfaces

### 1. `server.py` — Tools MCP

Módulo principal que expõe as tools via FastMCP com transporte stdio.

```python
# server.py
from mcp.server.fastmcp import FastMCP
from pack_mcp_google_chat_notification.client import get_client
from pack_mcp_google_chat_notification.errors import handle_error

mcp = FastMCP("pack-mcp-google-chat-notification")

@mcp.tool()
@handle_error
async def send_message(text: str, webhook_url: str | None = None) -> dict:
    """Envia mensagem de texto simples para o Google Chat."""
    ...

@mcp.tool()
@handle_error
async def send_card(
    title: str,
    sections: list[dict],
    subtitle: str | None = None,
    webhook_url: str | None = None,
) -> dict:
    """Envia mensagem com Card V2 para o Google Chat."""
    ...

@mcp.tool()
@handle_error
async def send_thread_message(
    text: str,
    thread_key: str,
    webhook_url: str | None = None,
) -> dict:
    """Envia mensagem em thread no Google Chat."""
    ...

def main():
    mcp.run(transport="stdio")
```

**Responsabilidades:**
- Validar parâmetros de entrada (text, title, sections, thread_key não vazios)
- Resolver webhook_url (parâmetro > variável de ambiente)
- Delegar envio HTTP ao `GoogleChatClient`
- Retornar JSON padronizado (`{ "success": true }`)

### 2. `client.py` — Google Chat Client (Singleton)

Módulo HTTP responsável pela comunicação com a API do Google Chat.

```python
# client.py
import httpx
import os
import logging
from pack_mcp_google_chat_notification.errors import sanitize_url

logger = logging.getLogger(__name__)

class GoogleChatClient:
    """Client HTTP singleton para comunicação com Google Chat Webhooks."""

    _instance: "GoogleChatClient | None" = None
    DEFAULT_TIMEOUT = 10.0

    def __init__(self, default_webhook_url: str | None = None):
        self._default_webhook_url = default_webhook_url
        self._http_client = httpx.AsyncClient(timeout=self.DEFAULT_TIMEOUT)

    @classmethod
    def get_instance(cls) -> "GoogleChatClient":
        if cls._instance is None:
            cls._instance = cls(
                default_webhook_url=os.environ.get("GOOGLE_CHAT_WEBHOOK_URL")
            )
        return cls._instance

    @classmethod
    def reset(cls) -> None:
        """Reset singleton (para testes)."""
        cls._instance = None

    @property
    def default_webhook_url(self) -> str | None:
        return self._default_webhook_url

    def resolve_webhook_url(self, webhook_url: str | None) -> str:
        """Resolve webhook URL: parâmetro > env var. Levanta ValueError se nenhum disponível."""
        ...

    def validate_webhook_url(self, url: str) -> None:
        """Valida que a URL começa com https://chat.googleapis.com/. Levanta ValueError se inválida."""
        ...

    async def post(self, webhook_url: str, payload: dict, params: dict | None = None) -> dict:
        """Envia POST para o webhook. Retorna response body ou levanta exceção."""
        ...


def get_client() -> GoogleChatClient:
    """Atalho para obter a instância singleton."""
    return GoogleChatClient.get_instance()
```

**Responsabilidades:**
- Gerenciar instância singleton do client HTTP
- Resolver e validar webhook URLs
- Enviar requisições POST com `httpx` (timeout 10s, Content-Type application/json)
- Logar URLs sanitizadas
- Tratar respostas HTTP (200 = sucesso, outros = erro com status + body)
- Tratar timeouts de conexão

### 3. `errors.py` — Tratamento de Erros e Sanitização

Módulo com o decorator centralizado de erros e a função de sanitização de URLs.

```python
# errors.py
import functools
import re
import logging
from urllib.parse import urlparse, parse_qs, urlencode, urlunparse
from mcp.shared.exceptions import McpError

logger = logging.getLogger(__name__)

SENSITIVE_PARAMS = {"key", "token"}

def sanitize_url(url: str) -> str:
    """Remove valores sensíveis (key, token) de uma webhook URL, substituindo por '***'."""
    ...

def handle_error(func):
    """Decorator que converte exceções em McpError com mensagens descritivas."""
    @functools.wraps(func)
    async def wrapper(*args, **kwargs):
        try:
            return await func(*args, **kwargs)
        except McpError:
            raise  # Já é McpError, propagar
        except ValueError as e:
            raise McpError(str(e))
        except Exception as e:
            logger.error("Erro interno: %s", e)
            raise McpError("Erro interno do servidor")
    return wrapper
```

**Responsabilidades:**
- `sanitize_url`: substituir valores de `key` e `token` por `***` em URLs de webhook
- `handle_error`: interceptar exceções e converter em `McpError`
  - `ValueError` → `McpError` com mensagem original
  - `McpError` → propagar sem alteração
  - Outras exceções → `McpError` com mensagem genérica

### 4. `__init__.py` — Metadados do Pacote

```python
# __init__.py
__version__ = "0.1.0"
```

## Modelos de Dados

### Payloads de Envio

#### Texto Simples (send_message)

```json
{
  "text": "<mensagem>"
}
```

#### Card V2 (send_card)

```json
{
  "cardsV2": [
    {
      "cardId": "<uuid-gerado>",
      "card": {
        "header": {
          "title": "<título>",
          "subtitle": "<subtítulo>"  // opcional
        },
        "sections": [
          {
            "header": "<header-seção>",  // opcional
            "widgets": [
              { "textParagraph": { "text": "..." } },
              { "decoratedText": { "topLabel": "...", "text": "..." } }
            ]
          }
        ]
      }
    }
  ]
}
```

#### Thread Message (send_thread_message)

Payload igual ao texto simples, com query parameters adicionais na URL:

```
?threadKey=<thread_key>&messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD
```

### Respostas

#### Sucesso

```json
{ "success": true }
```

Para `send_thread_message`:
```json
{ "success": true, "thread_key": "<thread_key>" }
```

#### Erro (via McpError)

Erros são propagados como `McpError` pelo decorator `handle_error`, com mensagens descritivas como:
- `"O texto da mensagem é obrigatório"`
- `"O título do card é obrigatório"`
- `"Nenhum webhook configurado. Forneça webhook_url ou defina GOOGLE_CHAT_WEBHOOK_URL"`
- `"URL do webhook inválida. Deve começar com https://chat.googleapis.com/"`
- `"Erro HTTP 400: <body da resposta>"`
- `"Timeout de conexão ao enviar mensagem para o Google Chat"`

### Funções de Construção de Payload

```python
def build_text_payload(text: str) -> dict:
    """Constrói payload de texto simples."""
    return {"text": text}

def build_card_payload(title: str, sections: list[dict], subtitle: str | None = None) -> dict:
    """Constrói payload Card V2."""
    ...

def parse_card_payload(payload: dict) -> tuple[str, list[dict], str | None]:
    """Extrai title, sections e subtitle de um payload Card V2."""
    ...
```

Estas funções ficam em `client.py` e são responsáveis pela serialização/deserialização dos payloads, garantindo a propriedade round-trip (Requisito 11.3).

## Propriedades de Corretude

*Uma propriedade é uma característica ou comportamento que deve ser verdadeiro em todas as execuções válidas de um sistema — essencialmente, uma declaração formal sobre o que o sistema deve fazer. Propriedades servem como ponte entre especificações legíveis por humanos e garantias de corretude verificáveis por máquina.*

### Propriedade 1: Validação de Webhook URL por prefixo

*Para qualquer* string URL, a validação deve aceitar a URL se e somente se ela começar com `https://chat.googleapis.com/`. URLs que não começam com esse prefixo devem ser rejeitadas com erro descritivo.

**Valida: Requisitos 4.1, 4.2**

### Propriedade 2: Sanitização de URLs remove tokens sensíveis

*Para qualquer* URL contendo parâmetros `key` e/ou `token` com valores arbitrários, `sanitize_url` deve retornar uma URL onde esses valores são substituídos por `***`, mantendo a estrutura da URL e todos os outros parâmetros intactos.

**Valida: Requisitos 7.1, 7.2**

### Propriedade 3: Round-trip de payload Card V2

*Para qualquer* combinação válida de `title` (não vazio), `sections` (lista não vazia de dicts) e `subtitle` (opcional), serializar com `build_card_payload` e depois deserializar com `parse_card_payload` deve produzir valores equivalentes aos originais.

**Valida: Requisitos 11.3**

### Propriedade 4: Serialização de payload de texto

*Para qualquer* string de texto não vazia, `build_text_payload(text)` deve produzir exatamente `{"text": text}`, e o valor no payload deve ser idêntico ao texto original.

**Valida: Requisitos 11.2, 1.1**

### Propriedade 5: Inclusão condicional de subtítulo no Card V2

*Para qualquer* payload Card V2 válido, o subtítulo deve estar presente no header do card se e somente se foi fornecido como parâmetro. Quando fornecido, o valor no payload deve ser idêntico ao original. Quando omitido, a chave `subtitle` não deve existir no header.

**Valida: Requisitos 2.2, 2.3**

### Propriedade 6: Construção de URL com parâmetros de thread

*Para qualquer* webhook URL válida e qualquer `thread_key` não vazio, a URL construída para envio em thread deve conter os query parameters `threadKey=<thread_key>` e `messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD`, preservando os parâmetros originais da URL.

**Valida: Requisitos 3.1**

### Propriedade 7: Identidade do singleton

*Para qualquer* sequência de chamadas a `GoogleChatClient.get_instance()`, todas devem retornar o mesmo objeto (mesma identidade `id()`). A primeira chamada cria a instância, e todas as subsequentes a reutilizam.

**Valida: Requisitos 9.1, 9.2**

### Propriedade 8: Conversão de exceções pelo decorator handle_error

*Para qualquer* exceção lançada dentro de uma função decorada com `@handle_error`, o decorator deve converter a exceção em `McpError`. Para `ValueError`, a mensagem original deve ser preservada. Para `McpError`, deve ser propagada sem alteração. Para qualquer outra exceção, deve produzir uma mensagem genérica de erro interno.

**Valida: Requisitos 6.1, 6.3**

### Propriedade 9: Fallback de webhook URL para variável de ambiente

*Para qualquer* valor de `GOOGLE_CHAT_WEBHOOK_URL` definido como variável de ambiente, quando `webhook_url` não é fornecido como parâmetro, o client deve utilizar o valor da variável de ambiente. O valor resolvido deve ser idêntico ao da variável de ambiente.

**Valida: Requisitos 1.2, 8.1**

### Propriedade 10: Tratamento de respostas HTTP

*Para qualquer* resposta HTTP recebida do Google Chat, o client deve retornar sucesso se e somente se o status code for 200. Para qualquer status code diferente de 200, o erro retornado deve conter o status code e o body da resposta.

**Valida: Requisitos 5.4, 5.5**

## Tratamento de Erros

### Estratégia de Erros

Todos os erros são centralizados pelo decorator `@handle_error` em `errors.py`, que converte exceções em `McpError`:

| Cenário | Exceção Original | McpError Mensagem |
|---------|-----------------|-------------------|
| Texto vazio | `ValueError` | "O texto da mensagem é obrigatório" |
| Título vazio | `ValueError` | "O título do card é obrigatório" |
| Sections vazia | `ValueError` | "Pelo menos uma seção é obrigatória" |
| Thread key vazia | `ValueError` | "A chave da thread é obrigatória" |
| Sem webhook | `ValueError` | "Nenhum webhook configurado. Forneça webhook_url ou defina GOOGLE_CHAT_WEBHOOK_URL" |
| URL inválida | `ValueError` | "URL do webhook inválida. Deve começar com https://chat.googleapis.com/" |
| HTTP != 200 | `HttpError` (custom) | "Erro HTTP {status}: {body}" |
| Timeout | `httpx.TimeoutException` | "Timeout de conexão ao enviar mensagem para o Google Chat" |
| Erro inesperado | `Exception` | "Erro interno do servidor" |

### Fluxo de Erros

```mermaid
graph TD
    A[Tool executada] --> B{Exceção?}
    B -->|Não| C[Retorna resultado]
    B -->|Sim| D{Tipo da exceção}
    D -->|McpError| E[Propaga sem alteração]
    D -->|ValueError| F[McpError com mensagem original]
    D -->|httpx.TimeoutException| G[McpError: timeout]
    D -->|Outra| H[McpError: erro interno]
```

### Sanitização em Logs

Antes de qualquer registro em log que contenha uma webhook URL, a função `sanitize_url` é chamada para substituir os valores dos parâmetros `key` e `token` por `***`. Exemplo:

```
# Entrada
https://chat.googleapis.com/v1/spaces/XXX/messages?key=AIza123&token=abc456

# Saída sanitizada
https://chat.googleapis.com/v1/spaces/XXX/messages?key=***&token=***
```

## Estratégia de Testes

### Abordagem Dual: Testes Unitários + Property-Based Testing

O projeto utiliza **pytest** para testes unitários e **hypothesis** para property-based testing. Ambos são complementares:

- **Testes unitários**: exemplos específicos, edge cases, condições de erro, integrações mockadas
- **Testes de propriedade**: propriedades universais validadas com inputs gerados aleatoriamente

### Biblioteca de Property-Based Testing

- **Biblioteca**: `hypothesis` (padrão Python para PBT)
- **Configuração**: mínimo de 100 iterações por teste de propriedade (`@settings(max_examples=100)`)
- **Cada teste de propriedade DEVE referenciar a propriedade do design via comentário**
- **Formato do tag**: `Feature: pack-mcp-google-chat-notification, Property {N}: {descrição}`
- **Cada propriedade de corretude DEVE ser implementada por UM ÚNICO teste de propriedade**

### Estrutura de Testes

```
tests/
├── __init__.py
├── conftest.py          # Fixtures compartilhadas (mock httpx, env vars, client reset)
├── strategies.py        # Estratégias Hypothesis para geração de dados
├── test_errors.py       # Testes de handle_error e sanitize_url
├── test_client.py       # Testes do GoogleChatClient
└── test_server.py       # Testes das tools MCP
```

### Estratégias Hypothesis (`strategies.py`)

Geradores customizados para os testes de propriedade:

- `valid_webhook_urls()`: URLs que começam com `https://chat.googleapis.com/` com parâmetros aleatórios
- `invalid_webhook_urls()`: URLs que NÃO começam com o prefixo correto
- `webhook_urls_with_tokens()`: URLs com parâmetros `key` e `token` com valores aleatórios
- `valid_texts()`: Strings não vazias para mensagens
- `valid_titles()`: Strings não vazias para títulos de cards
- `valid_sections()`: Listas não vazias de dicts com estrutura de seção válida
- `valid_thread_keys()`: Strings não vazias para chaves de thread
- `card_payloads()`: Combinações válidas de title + sections + subtitle opcional

### Mapeamento Propriedades → Testes

| Propriedade | Arquivo de Teste | Tipo |
|-------------|-----------------|------|
| P1: Validação de URL | `test_client.py` | Property (hypothesis) |
| P2: Sanitização de URL | `test_errors.py` | Property (hypothesis) |
| P3: Round-trip Card V2 | `test_client.py` | Property (hypothesis) |
| P4: Serialização de texto | `test_client.py` | Property (hypothesis) |
| P5: Subtítulo condicional | `test_client.py` | Property (hypothesis) |
| P6: URL com thread params | `test_client.py` | Property (hypothesis) |
| P7: Singleton identity | `test_client.py` | Property (hypothesis) |
| P8: Conversão de exceções | `test_errors.py` | Property (hypothesis) |
| P9: Fallback webhook URL | `test_client.py` | Property (hypothesis) |
| P10: Respostas HTTP | `test_client.py` | Property (hypothesis) |

### Testes Unitários (Exemplos e Edge Cases)

- **Edge cases de validação**: texto vazio, título vazio, sections vazia, thread_key vazia (Requisitos 1.3, 1.4, 2.4, 2.5, 3.2, 3.3)
- **Timeout de conexão**: mock de `httpx.TimeoutException` (Requisito 5.6)
- **Content-Type**: verificar header da requisição (Requisito 5.1)
- **Timeout padrão**: verificar configuração de 10s (Requisito 5.3)
- **Log level**: verificar configuração via env var e default WARNING (Requisitos 8.2, 8.3)
- **Inicialização singleton com env vars**: verificar que usa env vars do momento da criação (Requisito 9.3)
