Snapshot: gti-scheduling-mcp (Z-PRO + Klingo adapters) e workflow n8n de agendamento funcionando de ponta a ponta

- gti-scheduling-mcp: MCP server com ZProAdapter e KlingoAdapter (agendar() completo pros dois)
- Workflow n8n 'Agendamento - GChat - Z-PRO': webhook -> AI Agent -> MCP -> Z-PRO, testado com sucesso real
- Documentacao: specs, plano, runbook de problemas reais e export do workflow

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Gustavo
2026-08-10 17:23:10 -03:00
co-authored by Claude Sonnet 5
commit 257afe98c0
48 changed files with 12465 additions and 0 deletions
+263
View File
@@ -0,0 +1,263 @@
# Runbook — Workflow de Agendamento no n8n (GChat/Z-PRO + gti-scheduling-mcp)
> Registra problemas reais encontrados ao montar o workflow
> `Agendamento - GChat - Z-PRO (clínica exemplo)` (id `yG2mi6ECBpPv4Hmn`, servidor
> `srvdevn8ngti` / `devn8n.gtigrupo.com.br`), e como foram resolvidos — para não
> repetir o mesmo processo de descoberta ao duplicar este workflow para uma nova
> clínica. Ver também `docs/superpowers/specs/2026-08-10-n8n-agendamento-workflow-design.md`
> (design) e `docs/superpowers/plans/2026-08-10-n8n-agendamento-workflow.md` (plano).
## 1. Node MCP: qual usar e como instalar
- O node **oficial** do n8n (`MCP Client Tool`, `@n8n/n8n-nodes-langchain`) só
conecta em MCP remoto via HTTP Streamable/SSE — **não** roda comando local
(stdio). Para rodar o `gti-scheduling-mcp` como subprocesso local, use o
pacote **comunitário** `n8n-nodes-mcp` (autor `nerding-io`).
- Instalação: **Settings → Community Nodes → Install a community node** →
`n8n-nodes-mcp`.
- Ao conectar como tool de um AI Agent, o **tipo do node no JSON do workflow**
precisa ser `n8n-nodes-mcp.mcpClientTool` (não `n8n-nodes-mcp.mcpClient`) —
é uma variante "Tool" que o n8n gera automaticamente para nodes marcados
como usáveis em AI Agents. Usar `mcpClient` na conexão `ai_tool` falha a
validação com "cannot output ai_tool connections".
- É necessário setar a variável de ambiente do **processo do n8n**
(não é configurável pela UI):
```
N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE=true
```
Nesse servidor, o n8n roda via **systemd** (`n8n.service`), com
`EnvironmentFile=/opt/n8n.env`. A variável foi adicionada nesse arquivo,
seguido de `systemctl restart n8n`. (Se o n8n rodasse em Docker, seria em
`environment:` no `docker-compose.yml`.)
## 2. Credencial MCP (STDIO) — dois bugs reais
Tipo de credencial: `mcpClientApi` ("MCP Client (STDIO) API"), campos:
`command`, `args`, `environments`.
- **Bug 1 — `command: "node"` sozinho falhou** com "Connection closed". O
serviço systemd tem um `PATH` mais restrito que um shell interativo. Fix:
usar o **caminho absoluto** (`which node` no servidor → `/usr/bin/node`).
- **Bug 2 — o campo "Environments" é um `<input>` de uma linha só (mascarado
como senha) na UI do n8n.** O código real do node
(`nodes/McpClient/McpClient.node.ts` no pacote `n8n-nodes-mcp`) espera o
formato `NOME=VALOR` **separado por quebra de linha real** (`split('\n')`),
mas navegadores removem quebras de linha ao digitar OU colar em campos de
uma linha — então múltiplas variáveis nunca conseguem ser inseridas
corretamente pela UI (tentamos: uma por linha, vírgula, espaço — todas
colapsam a mesma forma, e a primeira variável acaba recebendo o resto do
texto como valor, quebrando a validação).
**Fix**: criar/recriar a credencial via **API REST do n8n**, que aceita o
JSON com `\n` de verdade (não passa pelo campo quebrado da UI):
```bash
# 1. Gerar uma API key: Settings → n8n API → Create an API key
# 2. DELETE na credencial antiga (se existir), depois POST na nova:
curl -X POST "https://devn8n.gtigrupo.com.br/api/v1/credentials" \
-H "X-N8N-API-KEY: <chave>" \
-H "Content-Type: application/json" \
-d '{
"name": "Client (STDIO) account",
"type": "mcpClientApi",
"data": {
"command": "/usr/bin/node",
"args": "/opt/gti-scheduling-mcp/dist/index.js",
"environments": "SCHEDULING_PROVIDER=zpro\nZPRO_BASE_URL=https://...\nZPRO_API_TOKEN=...\nZPRO_API_ID=..."
}
}'
```
Sintoma quando esse bug está presente: o log do n8n
(`journalctl -u n8n -f`) mostra exatamente a mensagem de erro do
`gti-scheduling-mcp` (`Falha ao carregar a configuração: SCHEDULING_PROVIDER
deve ser "zpro" ou "klingo".`), seguida de `MCP error -32000: Connection
closed` — o processo sobe e cai na validação de config antes de completar o
handshake stdio.
## 3. Deploy do `gti-scheduling-mcp` no servidor do n8n
O `node dist/index.js` executado pela credencial precisa estar no **mesmo
host** onde o n8n roda (o node comunitário spawna o processo localmente).
Processo usado (sem repositório git configurado ainda neste projeto):
`tar` local (excluindo `node_modules`/`dist`) → `scp` pro servidor → `npm
install && npm run build` lá. Publicado em `/opt/gti-scheduling-mcp`.
## 4. Modelo de IA (OpenAI Chat Model node) — gotchas de configuração
Mesmo usando um provider "compatível com OpenAI" (ex.: Z.ai/GLM), o node
`OpenAI Chat Model` (`@n8n/n8n-nodes-langchain.lmChatOpenAi`, v1.3) tem
comportamento por padrão que quebra com endpoints não-OpenAI:
- **`responsesApiEnabled` é `true` por padrão** nessa versão — usa a
"Responses API" da OpenAI (`/responses`), um endpoint proprietário que
providers OpenAI-compatíveis geralmente não implementam (eles só têm
`/chat/completions`). Sintoma: `404 Not Found` /
`MODEL_NOT_FOUND`. **Fix**: setar `responsesApiEnabled: false`
explicitamente.
- **Campo `model`** é um `resourceLocator` — precisa de `{mode: "id", value:
"nome-do-modelo"}` explícito. Omitir `mode` pode fazer o n8n tentar listar
modelos via uma chamada de API (`GET /models`) que o provider alternativo
pode não suportar do mesmo jeito, gerando erro parecido com o anterior.
- **Base URL da credencial**: o campo correto na credencial `openAiApi` é
`url` (aparece como "Base URL" na UI, geralmente atrás de "Add Option").
## 5. Expressões em sub-nodes de IA (Memory/Tools) dentro do AI Agent
Dentro de nodes conectados via `ai_memory`/`ai_tool`/`ai_languageModel`
(sub-nodes do AI Agent), referenciar um node upstream explicitamente
(`={{ $('Normalizar entrada').item.json.sessionId }}`) falhou com "Key
parameter is empty" — mesmo com o dado presente no item que chegou no AI
Agent. **Fix**: usar `={{ $json.sessionId }}` (referência ao item atual, sem
nome de node) — é o padrão que o próprio n8n usa por padrão nesse campo, e
funciona porque sub-nodes de IA herdam o item do node pai (AI Agent) na
execução.
## 6. Contrato do webhook do Z-PRO/GChat (verificado, não documentado
oficialmente em lugar nenhum)
- Uma execução do workflow **por mensagem** do cliente — o Z-PRO não
enfileira mensagens seguintes se o workflow não responder imediatamente.
- Corpo recebido: `{step, entrada, number, contactName, protocol}` — `step`
deve ser `"process"`; `protocol` é o identificador do **atendimento**
(não do telefone).
- Resposta esperada: **texto puro**, nunca JSON — node `Respond to Webhook`
em modo `text`.
## 7. Z.ai/GLM como modelo temporário — limitação real de tool-calling
Usado como substituto temporário enquanto a chave da OpenAI não chegava.
Dois endpoints testados:
- **`https://api.z.ai/api/coding/paas/v4`** ("GLM Coding Plan"): funciona
bem para chat simples, mas é **restrito a ferramentas oficialmente
suportadas** (Claude Code, Goose, etc. — ver
`https://docs.z.ai/devpack/faq`). Ao anexar uma tool MCP genérica ao AI
Agent, o modelo passou a devolver respostas **vazias silenciosamente**
(sem erro) em qualquer mensagem, mesmo sem chamar a tool. Confirmado
desabilitando a tool: a conversa voltou a funcionar normalmente.
- **`https://api.z.ai/api/paas/v4/`** (endpoint geral, mesma conta): suporta
tool-calling arbitrário segundo a documentação, mas apresentou timeout
consistente nesta conta/plano (possivelmente saldo separado do "Coding
Plan").
- **Conclusão**: Z.ai/GLM não é uma substituição direta e confiável da
OpenAI para um AI Agent com tools neste projeto. Recomendado usar a chave
real da OpenAI (ou outro provider com suporte robusto e testado de
function-calling) assim que disponível — só trocar a credencial e o
`model`/`responsesApiEnabled` de volta, sem tocar no resto do workflow.
## 8. Tratamento de erro adicionado
`Normalizar entrada` e `AI Agent` têm `onError: "continueErrorOutput"`,
ambos roteados para um node `Set` ("Erro - Resposta padrão") com uma
mensagem fixa amigável, que alimenta o `Respond to Webhook`. Isso garante
que o Z-PRO **sempre** recebe alguma resposta, mesmo se a normalização
lançar uma exceção ou o AI Agent falhar (timeout, erro de modelo, etc.) —
testado com um timeout real do endpoint geral do Z.ai, confirmado
funcionando.
## 9. Bug crítico — `toolParameters` do node MCP nunca era preenchido pelo modelo
**Sintoma**: toda chamada de `agendar` falhava com
`MCP error -32602: Input validation error: Invalid arguments for tool
agendar: Required at clinicId, Required at startAt, Required at endAt,
Required at patient.` — mesmo com o prompt do sistema explicando o formato
esperado em detalhe.
**Causa raiz**: o node `n8n-nodes-mcp` (operação `executeTool`) tem um
campo próprio **`toolParameters`** (JSON, obrigatório, valor padrão
`'{}'`) que carrega os argumentos reais enviados à tool. Ele **não é
preenchido automaticamente** só por a tool estar conectada a um AI Agent —
é preciso configurá-lo explicitamente com o helper `$fromAI()` do n8n, que
é o mecanismo real usado para "deixar o modelo decidir este valor em cada
chamada". Sem isso, o campo fica travado no `{}` padrão, e nenhum valor
que o modelo "pensa" que está enviando chega de fato ao MCP.
**Fix**: configurar `toolParameters` assim (mistura valores fixos vindos
do próprio workflow com `$fromAI()` só para o que o modelo precisa mesmo
decidir):
```
={{ JSON.stringify({
clinicId: $('Normalizar entrada').item.json.clinicId,
startAt: $fromAI('startAt', 'Data e hora de início, ISO 8601 completo', 'string'),
endAt: $fromAI('endAt', 'Data e hora de término, ISO 8601 completo', 'string'),
patient: {
name: $fromAI('patientName', 'Nome completo confirmado do paciente', 'string'),
phone: $('Normalizar entrada').item.json.telefone
}
}) }}
```
## 10. Bug crítico — `systemMessage` do AI Agent nunca era avaliado como expressão
**Sintoma**: um agendamento de teste foi criado com o campo de telefone
contendo literalmente o texto `{{ $('Normalizar entrada').item.json.telefone
}}` em vez de um número de telefone real.
**Causa raiz**: o campo `parameters.options.systemMessage` do node AI Agent
só é avaliado como expressão do n8n se o **valor inteiro do campo** começar
com `=`. Sem esse prefixo, tudo — inclusive trechos `{{ ... }}` escritos
"como exemplo" dentro do texto — é enviado ao modelo como **texto literal**,
e o modelo, sem saber que aquilo deveria virar um valor real, às vezes
copia o placeholder ao pé da letra.
**Fix**: sempre prefixar o campo inteiro com `=` quando ele precisar
referenciar dados reais do workflow (`={{ $('Normalizar entrada')... }}`).
**Consequência que isso causou (transparência total)**: dois agendamentos
de teste fictícios ficaram duplicados no Z-PRO (`Consulta - TESTE-N8N
Paciente Final`, mesma data/hora) antes desse bug ser identificado — uma
chamada que o agente reportou como "erro" na verdade teve sucesso
silenciosamente no lado do Z-PRO. Os testes fictícios foram apagados
manualmente depois. Isso reforça por que a disciplina de "uma execução por
vez, conferir visualmente antes de tentar de novo" existe em todos os
testes reais deste projeto — mesmo seguindo ela, um bug de configuração
ainda pode gerar um resultado inesperado, e só a conferência visual pega
isso.
## 11. Bug — modelo não sabe a data de hoje
**Sintoma**: pedido de agendamento para "amanhã" resultou em uma data no
passado (2023) sendo enviada ao Z-PRO.
**Causa raiz**: modelos de linguagem não têm acesso à data/hora real do
momento da conversa — sem essa informação no prompt, o modelo "chuta" uma
data (geralmente relacionada à data de corte do treinamento).
**Fix**: incluir a data/hora atual no `systemMessage` via expressão do
próprio n8n:
```
A data de hoje é {{ $now.setZone('America/Sao_Paulo').toFormat('yyyy-MM-dd (cccc)') }}
```
## 12. Bug — horário criado 3h adiantado/atrasado (fuso horário)
**Sintoma**: cliente pediu "sexta às 10h", o agendamento foi criado em produção
às 7h — exatamente 3h a menos (o offset de America/Sao_Paulo, UTC-3).
**Causa raiz**: o exemplo de formato dado ao modelo usava sufixo `Z` (UTC)
— ex. `2026-04-20T18:00:00.000Z`. O modelo tratou o horário local que o
cliente pediu (10h) como se já fosse UTC, sem converter, e o Z-PRO (que
aparentemente armazena/exibe em UTC) mostrou 10h UTC como 7h local.
**Fix**: instruir o modelo a **nunca usar `Z`/UTC** e sempre escrever o
mesmo número de hora que o cliente pediu, com offset `-03:00` explícito
(ex.: cliente pediu 10h → `2026-08-14T10:00:00-03:00`). Isso evita
qualquer aritmética de fuso horário pelo modelo — ele só copia o número
que o cliente já disse, sem calcular nada. Ajustado tanto na descrição do
`$fromAI('startAt', ...)`/`$fromAI('endAt', ...)` quanto numa regra
explícita no `systemMessage`.
**Se isso acontecer nas próximas clínicas**: o offset `-03:00` só está
certo para fuso de Brasília. Se uma clínica futura estiver em outro fuso,
ajustar o offset (e o `$now.setZone(...)` do prompt) de acordo.
## Status final (10/08/2026)
Testado de ponta a ponta com sucesso, com a chave real da OpenAI
(`gpt-4o-mini`): telefone correto, data correta, um único agendamento por
teste, confirmado visualmente no Z-PRO. Workflow deixado **desativado**
propositalmente — só ativar quando o Z-PRO de uma clínica real for
configurado para apontar pra esse webhook.
@@ -0,0 +1,188 @@
{
"name": "Agendamento - GChat - Z-PRO (clínica exemplo)",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "agendamento-zpro-clinica-exemplo",
"responseMode": "responseNode",
"options": {}
},
"id": "webhook-zpro",
"name": "Webhook Z-PRO",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2.1,
"position": [-208, -176]
},
{
"parameters": {
"respondWith": "text",
"responseBody": "={{ $json.output }}",
"options": {}
},
"id": "respond-stub",
"name": "Respond to Webhook",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.5,
"position": [224, 48]
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "const body = $input.item.json.body || {};\n\n// Configuração fixa desta cópia do workflow. Ajustar por clínica ao\n// duplicar este workflow para uma nova instância.\nconst clinicConfig = {\n provider: \"zpro\",\n clinicId: \"clinica-exemplo\",\n};\n\nif (body.step !== \"process\") {\n return {\n json: {\n earlyReply: true,\n sessionId: body.protocol || \"\",\n mensagem: \"\",\n nomeContato: \"\",\n telefone: \"\",\n provider: clinicConfig.provider,\n clinicId: clinicConfig.clinicId,\n },\n };\n}\n\nreturn {\n json: {\n earlyReply: false,\n sessionId: body.protocol || \"\",\n mensagem: String(body.entrada || \"\").trim(),\n nomeContato: body.contactName || \"\",\n telefone: body.number || \"\",\n provider: clinicConfig.provider,\n clinicId: clinicConfig.clinicId,\n },\n};"
},
"id": "normalizar-entrada",
"name": "Normalizar entrada",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [-16, -176],
"onError": "continueErrorOutput"
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"combinator": "and",
"conditions": [
{
"id": "cond-early-reply",
"leftValue": "={{ $json.earlyReply }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
]
},
"options": {}
},
"id": "if-early-reply",
"name": "É step=process?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [240, -336]
},
{
"parameters": {
"model": { "mode": "id", "value": "gpt-4o-mini" },
"builtInTools": {},
"options": {}
},
"id": "openai-chat-model",
"name": "OpenAI Chat Model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.3,
"position": [512, -64],
"credentials": {
"openAiApi": { "id": "PRECISA_RECRIAR_NO_SERVIDOR_NOVO", "name": "GPT" }
}
},
{
"parameters": {
"sessionIdType": "customKey",
"sessionKey": "={{ $json.sessionId }}",
"contextWindowLength": 20
},
"id": "memoria-sessao",
"name": "Memória da Sessão",
"type": "@n8n/n8n-nodes-langchain.memoryBufferWindow",
"typeVersion": 1.4,
"position": [672, -96]
},
{
"parameters": {
"promptType": "define",
"text": "={{ $json.mensagem }}",
"options": {
"systemMessage": "=Você é o assistente de agendamento da clínica, conversando por WhatsApp. A data de hoje é {{ $now.setZone('America/Sao_Paulo').toFormat('yyyy-MM-dd (cccc)') }}, fuso America/Sao_Paulo — use isso pra calcular qualquer data relativa (\"amanhã\", \"sexta que vem\", etc.), nunca assuma outro ano. clinicId e telefone do paciente já são preenchidos automaticamente pelo sistema — você só precisa decidir data/hora e confirmar o nome completo.\n\nRegras:\n1. O nome de contato do WhatsApp é \"{{ $('Normalizar entrada').item.json.nomeContato }}\" e o telefone é \"{{ $('Normalizar entrada').item.json.telefone }}\" — mas SEMPRE confirme o nome completo do paciente com o cliente antes de agendar, mesmo que já tenha o nome de contato.\n2. Pergunte a data e o horário desejados para a consulta, e opcionalmente o motivo. O horário que o cliente disser (ex.: \"10h\") é sempre horário local de Brasília (America/Sao_Paulo, UTC-3) — use esse mesmo número de hora ao montar startAt/endAt, com offset -03:00 (nunca converta pra UTC/Z, nunca some ou subtraia horas).\n3. Nunca invente informação que o cliente não deu.\n4. Responda sempre em português, em tom cordial e objetivo, adequado para WhatsApp (mensagens curtas).\n5. Quando tiver o nome completo confirmado e a data/hora desejada, você pode agendar de verdade.\n6. NUNCA agende mais de uma vez na mesma conversa — se o cliente já confirmou um agendamento nesta conversa, não agende de novo; informe que o agendamento já foi feito.\n7. Se o agendamento falhar, explique o problema ao cliente em linguagem simples e pergunte se ele quer tentar de novo com outros dados, ou avise que vai encaminhar para um atendente."
}
},
"id": "agente-agendamento",
"name": "AI Agent",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"position": [560, -320],
"onError": "continueErrorOutput"
},
{
"parameters": {
"operation": "executeTool",
"toolName": "agendar",
"toolParameters": "={{ JSON.stringify({ clinicId: $('Normalizar entrada').item.json.clinicId, startAt: $fromAI('startAt', 'Data e hora de inicio da consulta, no horario local pedido pelo cliente, em ISO 8601 com offset -03:00 (America/Sao_Paulo) -- NUNCA use Z/UTC. Exemplo: cliente pediu 10h -> 2026-08-14T10:00:00-03:00', 'string'), endAt: $fromAI('endAt', 'Data e hora de termino da consulta, mesmo formato do startAt (offset -03:00, nunca Z/UTC), geralmente 30 minutos apos o startAt', 'string'), patient: { name: $fromAI('patientName', 'Nome completo do paciente, ja confirmado com o cliente na conversa', 'string'), phone: $('Normalizar entrada').item.json.telefone } }) }}"
},
"id": "mcp-tool-agendar",
"name": "MCP - gti-scheduling-mcp",
"type": "n8n-nodes-mcp.mcpClientTool",
"typeVersion": 1,
"position": [1024, -128],
"credentials": {
"mcpClientApi": { "id": "PRECISA_RECRIAR_NO_SERVIDOR_NOVO", "name": "Client (STDIO) account" }
}
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "erro-output",
"name": "output",
"type": "string",
"value": "Desculpe, tive um problema técnico agora e não consegui continuar. Pode tentar de novo em alguns minutos, ou aguardar que alguém da nossa equipe vai te atender."
}
]
},
"options": {}
},
"id": "erro-resposta-padrao",
"name": "Erro - Resposta padrão",
"type": "n8n-nodes-base.set",
"typeVersion": 3.5,
"position": [240, -176]
}
],
"connections": {
"Webhook Z-PRO": {
"main": [[{ "node": "Normalizar entrada", "type": "main", "index": 0 }]]
},
"Normalizar entrada": {
"main": [
[{ "node": "É step=process?", "type": "main", "index": 0 }],
[{ "node": "Erro - Resposta padrão", "type": "main", "index": 0 }]
]
},
"OpenAI Chat Model": {
"ai_languageModel": [[{ "node": "AI Agent", "type": "ai_languageModel", "index": 0 }]]
},
"Memória da Sessão": {
"ai_memory": [[{ "node": "AI Agent", "type": "ai_memory", "index": 0 }]]
},
"É step=process?": {
"main": [
[],
[{ "node": "AI Agent", "type": "main", "index": 0 }]
]
},
"AI Agent": {
"main": [
[{ "node": "Respond to Webhook", "type": "main", "index": 0 }],
[{ "node": "Erro - Resposta padrão", "type": "main", "index": 0 }]
]
},
"MCP - gti-scheduling-mcp": {
"ai_tool": [[{ "node": "AI Agent", "type": "ai_tool", "index": 0 }]]
},
"Erro - Resposta padrão": {
"main": [[{ "node": "Respond to Webhook", "type": "main", "index": 0 }]]
}
},
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,469 @@
# Workflow n8n de Agendamento (GChat/Z-PRO → gti-scheduling-mcp) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Construir, no n8n (`https://devn8n.gtigrupo.com.br`), o primeiro workflow que recebe mensagens de clientes via webhook do Z-PRO/GChat, conduz a conversa com um AI Agent para capturar os dados do agendamento, e chama a tool `agendar` do `gti-scheduling-mcp` (via `ZProAdapter`) para realizar o agendamento de fato.
**Architecture:** Webhook (Z-PRO) → node `Code` de normalização/guarda de `step` → `AI Agent` (OpenAI + memória de sessão por `protocol` + tool MCP `agendar`) → `Respond to Webhook` (texto puro). Construído incrementalmente: cada task adiciona uma camada e é validada isoladamente antes da próxima.
**Tech Stack:** n8n (self-hosted, `devn8n.gtigrupo.com.br`), node `n8n-nodes-mcp` (community, transporte stdio), `gti-scheduling-mcp` (já implementado, sem alterações de código nesta etapa), OpenAI Chat Model.
## Global Constraints
- Nenhuma alteração de código/arquitetura no `gti-scheduling-mcp` é necessária ou permitida nesta etapa — ele já expõe a tool `agendar` exatamente como o workflow precisa consumir.
- O webhook precisa **sempre responder de imediato**, uma execução por mensagem — nunca segurar a resposta esperando mais mensagens (Z-PRO descarta mensagens se isso acontecer).
- A resposta ao Z-PRO é **sempre texto puro**, nunca JSON.
- Nenhuma credencial (token do Z-PRO, API key da OpenAI) é inserida por quem executa este plano de forma automática — todas vêm de Credentials do n8n, criadas manualmente pelo usuário na UI.
- `sessionId` da memória do agente = `protocol` do payload do Z-PRO (nunca o telefone — o mesmo telefone pode ter vários atendimentos/protocolos).
- Escopo: só `agendar()` via `ZProAdapter`. Nada de `consultar_horarios`, cancelamento, remarcação ou múltiplas clínicas nesta etapa (ver spec, seção "Fora de escopo").
Spec de referência: `docs/superpowers/specs/2026-08-10-n8n-agendamento-workflow-design.md`
---
## Pré-requisitos (ação manual do usuário — fora do escopo de execução automática)
Estes itens **não podem ser feitos pelas tasks abaixo** (exigem acesso à infraestrutura do n8n/credenciais reais) e precisam estar prontos antes da Task 5:
1. **Instalar o node comunitário `n8n-nodes-mcp`** no n8n de vocês, seguindo
`https://docs.n8n.io/integrations/community-nodes/installation/`.
2. **Definir a variável de ambiente `N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE=true`**
no processo do n8n (ex.: no `docker-compose.yml` do serviço n8n) e reiniciar o n8n — sem isso, o node comunitário não pode ser usado como tool dentro do AI Agent.
3. **Criar uma Credential do tipo OpenAI** no n8n (Settings → Credentials → OpenAI API), com uma API key válida.
4. **Criar uma Credential do tipo "MCP Client (Command Line) API"** (fornecida pelo node `n8n-nodes-mcp` após instalado), com:
- **Command**: `node`
- **Arguments**: caminho absoluto para `dist/index.js` do `gti-scheduling-mcp` no servidor onde o n8n roda (ex.: `/opt/gti-scheduling-mcp/dist/index.js`) — depende de onde o projeto for implantado, que é uma decisão de infraestrutura fora do escopo deste plano.
- **Environment Variables** (uma por linha, formato `NOME=valor`):
```
SCHEDULING_PROVIDER=zpro
ZPRO_BASE_URL=<url real da clínica>
ZPRO_API_TOKEN=<token real da clínica>
ZPRO_API_ID=<ApiID real da clínica — o GUID, não o número do registro>
```
Sem os 4 itens acima, a Task 5 (conectar a tool MCP ao Agent) não pode ser validada de ponta a ponta — as Tasks 1 a 4 não dependem deles.
---
### Task 1: Build do `gti-scheduling-mcp` e localização do `dist/index.js`
**Files:**
- Nenhum arquivo novo — apenas build do projeto existente.
**Interfaces:**
- Produces: caminho absoluto de `dist/index.js`, usado nas Tasks 5 e no pré-requisito 4 acima.
- [ ] **Step 1: Rodar o build**
```bash
cd "D:\PROJETOS\KLINGO-CLINICAL-SYSLIFE-GCHAT-AGENDAMENTO\gti-scheduling-mcp"
npm run build
```
Esperado: `tsc -p tsconfig.json` sem erros, e a pasta `dist/` criada/atualizada.
- [ ] **Step 2: Confirmar que `dist/index.js` existe e roda**
```bash
node "D:\PROJETOS\KLINGO-CLINICAL-SYSLIFE-GCHAT-AGENDAMENTO\gti-scheduling-mcp\dist\index.js" --help 2>&1 | head -5
```
Esperado: o processo inicia (mesmo que não reconheça `--help`, não deve dar erro de módulo não encontrado). Se o servidor esperar variáveis de ambiente obrigatórias e falhar por causa disso, isso confirma que o build está correto (o processo rodou, só não tinha config — comportamento esperado sem env vars).
Anote o caminho absoluto de `dist/index.js` — será necessário nos pré-requisitos e na Task 5.
---
### Task 2: Esqueleto base do webhook (Webhook → Respond to Webhook)
**Files:** Nenhum arquivo local — cria o workflow no n8n via `n8n_create_workflow`.
**Interfaces:**
- Produces: `workflowId` (retornado pela chamada) — usado em todas as tasks seguintes.
- [ ] **Step 1: Criar o workflow com dois nodes ligados diretamente**
Chame a tool `n8n_create_workflow` com:
```json
{
"name": "Agendamento - GChat - Z-PRO (clínica exemplo)",
"nodes": [
{
"id": "webhook-zpro",
"name": "Webhook Z-PRO",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2.1,
"position": [0, 0],
"parameters": {
"httpMethod": "POST",
"path": "agendamento-zpro-clinica-exemplo",
"responseMode": "responseNode"
}
},
{
"id": "respond-stub",
"name": "Respond to Webhook",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.5,
"position": [300, 0],
"parameters": {
"respondWith": "text",
"responseBody": "teste ok"
}
}
],
"connections": {
"Webhook Z-PRO": {
"main": [[{ "node": "Respond to Webhook", "type": "main", "index": 0 }]]
}
}
}
```
Guarde o `id` do workflow retornado — chamado de `<workflowId>` no restante deste plano.
- [ ] **Step 2: Validar o workflow**
Chame `n8n_validate_workflow` com `{"id": "<workflowId>"}`. Esperado: sem erros (pode haver avisos sobre o workflow estar inativo — normal, só ativamos na Task 6).
- [ ] **Step 3: Ativar temporariamente e testar a resposta em texto puro**
Ative o workflow (`n8n_update_partial_workflow` com operação `{"type": "activateWorkflow"}`), pegue a URL de produção do webhook (visível no node Webhook Z-PRO / na resposta da criação), e chame:
```bash
curl -i -X POST "https://devn8n.gtigrupo.com.br/webhook/agendamento-zpro-clinica-exemplo" \
-H "Content-Type: application/json" \
-d '{"step":"process","entrada":"oi","number":"5511999999999","contactName":"Teste","protocol":"protocolo-teste-1"}'
```
Esperado: HTTP 200, corpo **exatamente** `teste ok` (texto puro, sem aspas de JSON ao redor).
- [ ] **Step 4: Desativar o workflow de novo**
`n8n_update_partial_workflow` com `{"type": "deactivateWorkflow"}` — mantemos desativado até a Task 6, para não responder de verdade a mensagens reais enquanto ainda está incompleto.
---
### Task 3: Node de normalização + guarda de `step`
**Files:** Nenhum arquivo local — edita o workflow `<workflowId>` via `n8n_update_partial_workflow`.
**Interfaces:**
- Consumes: `<workflowId>` da Task 2.
- Produces: campos `sessionId`, `mensagem`, `nomeContato`, `telefone`, `provider`, `clinicId`, `earlyReply` — consumidos pela Task 4 (AI Agent) e pelo `If` desta task.
- [ ] **Step 1: Adicionar o node `Code` de normalização**
```json
{
"type": "addNode",
"node": {
"id": "normalizar-entrada",
"name": "Normalizar entrada",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [150, -120],
"parameters": {
"mode": "runOnceForEachItem",
"language": "javaScript",
"jsCode": "const body = $input.item.json.body || {};\n\n// Configuração fixa desta cópia do workflow. Ajustar por clínica ao\n// duplicar este workflow para uma nova instância.\nconst clinicConfig = {\n provider: \"zpro\",\n clinicId: \"clinica-exemplo\",\n};\n\nif (body.step !== \"process\") {\n return {\n json: {\n earlyReply: true,\n sessionId: body.protocol || \"\",\n mensagem: \"\",\n nomeContato: \"\",\n telefone: \"\",\n provider: clinicConfig.provider,\n clinicId: clinicConfig.clinicId,\n },\n };\n}\n\nreturn {\n json: {\n earlyReply: false,\n sessionId: body.protocol || \"\",\n mensagem: String(body.entrada || \"\").trim(),\n nomeContato: body.contactName || \"\",\n telefone: body.number || \"\",\n provider: clinicConfig.provider,\n clinicId: clinicConfig.clinicId,\n },\n};"
}
}
}
```
- [ ] **Step 2: Adicionar o node `If` que decide entre resposta antecipada e fluxo normal**
```json
{
"type": "addNode",
"node": {
"id": "if-early-reply",
"name": "É step=process?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [400, -120],
"parameters": {
"conditions": {
"options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict" },
"combinator": "and",
"conditions": [
{
"id": "cond-early-reply",
"leftValue": "={{ $json.earlyReply }}",
"rightValue": true,
"operator": { "type": "boolean", "operation": "true", "singleValue": true }
}
]
}
}
}
}
```
- [ ] **Step 3: Rewire — Webhook → Normalizar entrada → If**
```json
[
{ "type": "removeConnection", "source": "Webhook Z-PRO", "target": "Respond to Webhook" },
{ "type": "addConnection", "source": "Webhook Z-PRO", "target": "Normalizar entrada" },
{ "type": "addConnection", "source": "Normalizar entrada", "target": "É step=process?" }
]
```
- [ ] **Step 4: Ligar a saída TRUE do If (índice 0) direto ao Respond to Webhook existente, com texto vazio**
```json
[
{ "type": "patchNodeField", "nodeName": "Respond to Webhook", "fieldPath": "parameters.responseBody", "patches": [{ "find": "teste ok", "replace": "={{ $json.mensagem || '' }}" }] },
{ "type": "addConnection", "source": "É step=process?", "sourceOutput": 0, "target": "Respond to Webhook" }
]
```
A saída FALSE (índice 1) do `If` fica sem conexão por enquanto — será ligada ao AI Agent na Task 4.
- [ ] **Step 5: Validar**
`n8n_validate_workflow` com `{"id": "<workflowId>"}`. Esperado: sem erros. Pode haver aviso de node com saída não conectada (`É step=process?` saída FALSE) — esperado nesta task, resolvido na Task 4.
- [ ] **Step 6: Testar os dois caminhos**
Ative o workflow, chame o webhook duas vezes:
```bash
curl -s -X POST "https://devn8n.gtigrupo.com.br/webhook/agendamento-zpro-clinica-exemplo" \
-H "Content-Type: application/json" \
-d '{"step":"outro","entrada":"x","number":"1","contactName":"y","protocol":"p1"}'
```
Esperado: corpo vazio (early reply, `step` diferente de `process`).
```bash
curl -s -X POST "https://devn8n.gtigrupo.com.br/webhook/agendamento-zpro-clinica-exemplo" \
-H "Content-Type: application/json" \
-d '{"step":"process","entrada":"Olá, quero marcar uma consulta","number":"5511999999999","contactName":"Fulano","protocol":"p2"}'
```
Esperado: corpo = `Olá, quero marcar uma consulta` (o `Respond to Webhook` está ecoando `mensagem` só para confirmar que os campos normalizados chegaram certos — será substituído pela resposta do Agent na Task 4).
Desative o workflow ao final (`deactivateWorkflow`), como na Task 2.
---
### Task 4: AI Agent com memória de sessão (sem tool ainda)
**Files:** Nenhum arquivo local — edita `<workflowId>`.
**Interfaces:**
- Consumes: `earlyReply` (falso), `sessionId`, `mensagem`, `nomeContato`, `telefone` da Task 3.
- Produces: texto de resposta do Agent, consumido pelo `Respond to Webhook`.
- [ ] **Step 1: Adicionar o node OpenAI Chat Model**
```json
{
"type": "addNode",
"node": {
"id": "openai-chat-model",
"name": "OpenAI Chat Model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1,
"position": [650, 100],
"parameters": {
"model": { "value": "gpt-4o-mini" },
"options": {}
},
"credentials": {
"openAiApi": { "id": "<ID da Credential OpenAI criada no pré-requisito 3>", "name": "OpenAI account" }
}
}
}
```
Substitua `<ID da Credential OpenAI criada no pré-requisito 3>` pelo ID real da credencial (visível na UI do n8n, em Settings → Credentials, ou via `n8n_manage_credentials` se disponível). Se preferir, este campo pode ser deixado para o usuário selecionar manualmente na UI do n8n após a criação do node — não bloqueia os steps seguintes.
- [ ] **Step 2: Adicionar o node Simple Memory**
```json
{
"type": "addNode",
"node": {
"id": "memoria-sessao",
"name": "Memória da Sessão",
"type": "@n8n/n8n-nodes-langchain.memoryBufferWindow",
"typeVersion": 1.4,
"position": [650, 250],
"parameters": {
"sessionIdType": "customKey",
"sessionKey": "={{ $('Normalizar entrada').item.json.sessionId }}",
"contextWindowLength": 20
}
}
}
```
- [ ] **Step 3: Adicionar o node AI Agent**
```json
{
"type": "addNode",
"node": {
"id": "agente-agendamento",
"name": "AI Agent",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"position": [650, -120],
"parameters": {
"promptType": "define",
"text": "={{ $json.mensagem }}",
"options": {
"systemMessage": "Você é o assistente de agendamento da clínica, conversando por WhatsApp. Regras:\n1. O nome de contato do WhatsApp é \"{{ $('Normalizar entrada').item.json.nomeContato }}\" e o telefone é \"{{ $('Normalizar entrada').item.json.telefone }}\" — mas SEMPRE confirme o nome completo do paciente com o cliente antes de agendar, mesmo que já tenha o nome de contato.\n2. Pergunte a data e o horário desejados para a consulta, e opcionalmente o motivo.\n3. Nunca invente informação que o cliente não deu.\n4. Responda sempre em português, em tom cordial e objetivo, adequado para WhatsApp (mensagens curtas).\n5. Quando tiver o nome completo confirmado e a data/hora desejada, você pode agendar de verdade.\n6. NUNCA agende mais de uma vez na mesma conversa — se o cliente já confirmou um agendamento nesta conversa, não agende de novo; informe que o agendamento já foi feito.\n7. Se o agendamento falhar, explique o problema ao cliente em linguagem simples e pergunte se ele quer tentar de novo com outros dados, ou avise que vai encaminhar para um atendente."
}
}
}
}
```
- [ ] **Step 4: Conectar Chat Model e Memory ao Agent, e a saída FALSE do If ao Agent**
```json
[
{ "type": "addConnection", "source": "OpenAI Chat Model", "sourceOutput": "ai_languageModel", "target": "AI Agent", "targetInput": "ai_languageModel" },
{ "type": "addConnection", "source": "Memória da Sessão", "sourceOutput": "ai_memory", "target": "AI Agent", "targetInput": "ai_memory" },
{ "type": "addConnection", "source": "É step=process?", "sourceOutput": 1, "target": "AI Agent" },
{ "type": "addConnection", "source": "AI Agent", "target": "Respond to Webhook" }
]
```
- [ ] **Step 5: Ajustar o Respond to Webhook para usar a saída do Agent**
```json
{
"type": "patchNodeField",
"nodeName": "Respond to Webhook",
"fieldPath": "parameters.responseBody",
"patches": [{ "find": "={{ $json.mensagem || '' }}", "replace": "={{ $json.output }}" }]
}
```
(`$json.output` é o campo de texto padrão que o node AI Agent produz.)
- [ ] **Step 6: Validar**
`n8n_validate_workflow` com `{"id": "<workflowId>"}`. Esperado: sem erros. Se aparecer erro sobre a credencial OpenAI ausente/inválida, ajuste o `credentials.openAiApi.id` (Step 1) ou peça para o usuário selecionar a credencial manualmente na UI antes de prosseguir.
- [ ] **Step 7: Testar a memória de sessão**
Ative o workflow. Envie duas mensagens com o **mesmo** `protocol`:
```bash
curl -s -X POST "https://devn8n.gtigrupo.com.br/webhook/agendamento-zpro-clinica-exemplo" \
-H "Content-Type: application/json" \
-d '{"step":"process","entrada":"Meu nome completo é João da Silva Teste","number":"5511999999999","contactName":"João","protocol":"p-memoria-1"}'
```
```bash
curl -s -X POST "https://devn8n.gtigrupo.com.br/webhook/agendamento-zpro-clinica-exemplo" \
-H "Content-Type: application/json" \
-d '{"step":"process","entrada":"Qual é o meu nome completo que eu te falei?","number":"5511999999999","contactName":"João","protocol":"p-memoria-1"}'
```
Esperado: a segunda resposta menciona "João da Silva Teste" — confirma que a memória por `sessionId=protocol` está funcionando entre execuções separadas do webhook.
Desative o workflow ao final.
---
### Task 5: Conectar a tool MCP `agendar` ao Agent
**Pré-requisito:** os 4 itens da seção "Pré-requisitos" no topo deste plano precisam estar prontos (node comunitário instalado, env var setada e n8n reiniciado, credenciais OpenAI e MCP criadas).
**Files:** Nenhum arquivo local — edita `<workflowId>`.
**Interfaces:**
- Consumes: credencial MCP stdio criada no pré-requisito 4.
- Produces: capacidade do Agent de chamar `agendar` de verdade.
- [ ] **Step 1: Adicionar o node MCP Client Tool**
```json
{
"type": "addNode",
"node": {
"id": "mcp-tool-agendar",
"name": "MCP - gti-scheduling-mcp",
"type": "n8n-nodes-mcp.mcpClientTool",
"typeVersion": 1,
"position": [900, 250],
"parameters": {
"connectionType": "cmd",
"include": "selected",
"includeTools": ["agendar"]
},
"credentials": {
"mcpClientCmdApi": { "id": "<ID da Credential MCP stdio criada no pré-requisito 4>", "name": "gti-scheduling-mcp (stdio)" }
}
}
}
```
Os nomes exatos do tipo de credencial (`mcpClientCmdApi`) e da propriedade `connectionType` podem variar conforme a versão instalada do pacote `n8n-nodes-mcp` — se a validação do Step 3 apontar um nome de campo diferente, ajuste conforme o que a própria UI do n8n mostrar ao adicionar o node manualmente (Settings do node → aba Credentials).
- [ ] **Step 2: Conectar o node como tool do Agent**
```json
{
"type": "addConnection",
"source": "MCP - gti-scheduling-mcp",
"sourceOutput": "ai_tool",
"target": "AI Agent",
"targetInput": "ai_tool"
}
```
- [ ] **Step 3: Validar**
`n8n_validate_workflow` com `{"id": "<workflowId>", "options": {"profile": "ai-friendly"}}`. Corrija qualquer erro de nome de campo/credencial apontado (ver nota no Step 1).
- [ ] **Step 4: Teste manual controlado, com dados fictícios, seguindo a mesma disciplina de segurança contra duplicidade do teste do `ZProAdapter`**
Ative o workflow. Envie, com um `protocol` novo e único:
```bash
curl -s -X POST "https://devn8n.gtigrupo.com.br/webhook/agendamento-zpro-clinica-exemplo" \
-H "Content-Type: application/json" \
-d '{"step":"process","entrada":"Oi, quero marcar uma consulta pra amanhã às 14h, meu nome completo é TESTE-N8N Paciente","number":"5511900000001","contactName":"Teste N8N","protocol":"p-e2e-1"}'
```
Continue a conversa (repetindo a chamada com o mesmo `protocol`, mudando só `entrada`) até o Agent confirmar o agendamento. **Antes de cada nova tentativa, se algo parecer ter falhado, confira visualmente no Z-PRO se o agendamento já foi criado** (mesma regra usada no teste manual do `ZProAdapter`) — não repita mensagens de confirmação "às cegas".
Confirme visualmente no Z-PRO que o agendamento fictício foi criado.
- [ ] **Step 5: Desativar o workflow ao final do teste**
`n8n_update_partial_workflow` com `{"type": "deactivateWorkflow"}` — só reativar definitivamente depois que o usuário revisar o resultado (Task 6).
---
### Task 6: Revisão final e ativação
**Files:** Nenhum arquivo local.
- [ ] **Step 1: Validação final completa**
`n8n_validate_workflow` com `{"id": "<workflowId>", "options": {"profile": "strict"}}`. Resolver qualquer erro/aviso restante.
- [ ] **Step 2: Apresentar o resumo ao usuário**
Antes de ativar definitivamente, mostrar ao usuário: URL do webhook de produção, nome do workflow, e pedir confirmação de que o Z-PRO já está configurado para apontar o fluxo de agendamento desta clínica para essa URL.
- [ ] **Step 3: Ativar**
Só ativar (`activateWorkflow`) depois da confirmação explícita do usuário no Step 2 — ativar aqui significa que mensagens reais de clientes passam a ser processadas.
---
## Observação sobre "commit"
Nem `D:\PROJETOS\KLINGO-CLINICAL-SYSLIFE-GCHAT-AGENDAMENTO` nem `gti-scheduling-mcp` são repositórios git — não há passo de `git commit` neste plano. O estado do workflow vive inteiramente no n8n; cada task é validada via `n8n_validate_workflow` e teste funcional (chamada real ao webhook), no lugar de testes automatizados de código.
@@ -0,0 +1,164 @@
# Design — Workflow n8n de Agendamento via GChat/Z-PRO + gti-scheduling-mcp
**Data:** 2026-08-10
**Status:** Aprovado pelo usuário, aguardando plano de implementação.
## Objetivo
Construir o primeiro workflow n8n que recebe mensagens de clientes via
GChat (webhook do Z-PRO), conduz a conversa com um agente de IA para
capturar os dados necessários e realiza o agendamento de fato, chamando a
tool `agendar` do `gti-scheduling-mcp` (que por sua vez delega ao Adapter
do sistema da clínica — Z-PRO neste MVP).
Escopo do MVP: **só agendamento** (`agendar`). Consulta de disponibilidade,
cancelamento e remarcação ficam fora desta etapa.
## Contexto verificado (não inventado)
Estes fatos vêm de um workflow real já em produção no n8n de referência
("Enforcer - GLPI"/fluxo de suporte via Z-PRO, código do node `1 - Decide`
compartilhado pelo usuário) e de consultas diretas ao n8n
(`devn8n.gtigrupo.com.br`) e ao registro de nodes via `n8n-mcp`:
- O Z-PRO chama o webhook do n8n **uma vez por mensagem do cliente** — não
agrupa/enfileira mensagens.
- O corpo da requisição (`req = $json.body`) tem os campos: `step`
(esperado `"process"`), `entrada` (texto da mensagem do cliente),
`number` (telefone), `contactName`, `protocol` (identificador único do
**atendimento**, não do telefone — o mesmo cliente pode ter vários
atendimentos/protocolos ao longo do tempo).
- **Se o workflow segurar a resposta** (esperando mais mensagens antes de
responder), o Z-PRO **descarta** as mensagens seguintes do cliente
(confirmado em teste real: 5 mensagens em sequência → 1 execução no
n8n). O workflow precisa responder a cada execução, sempre.
- A resposta ao Z-PRO é **texto puro** (não JSON), via node `Respond to
Webhook` em modo texto.
- O node oficial `MCP Client Tool` (`@n8n/n8n-nodes-langchain`) só suporta
transporte HTTP Streamable/SSE — **não** suporta rodar um comando local
via stdio.
- O node comunitário `n8n-nodes-mcp` (`mcpClient`/`mcpClientTool`) suporta
conexão via linha de comando (stdio) — é o caminho escolhido para não
precisar alterar o transporte do `gti-scheduling-mcp` (que hoje só
implementa `StdioServerTransport`).
- O `gti-scheduling-mcp` (`ZProAdapter`/`KlingoAdapter`) já está pronto e
testado para `agendar()` — nenhuma mudança de arquitetura nele é
necessária para este workflow.
## Arquitetura
```
[Cliente WhatsApp]
│
▼
[Z-PRO/GChat] ──webhook──▶ [n8n: Webhook] → [Normalizar entrada] → [AI Agent] → [Respond to Webhook]
│ ▲
(tool call) │ │ (memória de sessão)
▼ │
[MCP Client Tool] [Simple Memory]
(stdio, agendar) (sessionId = protocol)
│
▼
[gti-scheduling-mcp: tool "agendar"]
│
▼
[AdapterFactory → ZProAdapter/KlingoAdapter]
│
▼
[Sistema real da clínica: Z-PRO/Klingo]
```
O `gti-scheduling-mcp` não sofre nenhuma alteração de arquitetura para
este workflow — o n8n é só mais um cliente MCP, como qualquer outro.
## Componentes (nós do n8n)
1. **Webhook (Z-PRO)** — `POST`, recebe `{step, entrada, number,
contactName, protocol}`.
2. **Normalizar entrada** (`Set`/`Code`) —
- Valida `step === "process"`; caso contrário, responde com um texto
mínimo e encerra (mesma defesa do fluxo de referência).
- Monta as variáveis usadas a seguir: `sessionId = protocol`,
`mensagem = entrada`, `nomeContato = contactName`, `telefone =
number`.
- **Nó de configuração da clínica** (fixo por cópia do workflow, não
dinâmico em runtime): `provider` (ex.: `"zpro"`), `clinicId`, e
referência de qual Credential do n8n / config do MCP Client Tool
usar para esta clínica. Cada clínica nova = uma cópia deste workflow
com esses valores ajustados e seu próprio webhook cadastrado no
Z-PRO — não há branching dinâmico de provider dentro de uma mesma
execução.
3. **AI Agent** (`@n8n/n8n-nodes-langchain.agent`) —
- Chat Model: OpenAI (credencial a configurar no n8n).
- System prompt cobre: confirmar o nome completo do paciente (mesmo já
tendo `contactName`), perguntar data/horário desejado, motivo
(opcional), nunca chamar `agendar` mais de uma vez por atendimento,
responder sempre em português, tom cordial, nunca inventar dado que
o cliente não informou.
- **Memória**: `Simple Memory` (`memoryBufferWindow`), `sessionId =
protocol`. Trade-off aceito para o MVP: essa memória vive na RAM do
processo do n8n — um restart do n8n no meio de um atendimento perde
o histórico da conversa. Evolução futura, se necessário: Postgres ou
Redis Chat Memory (durável, mas exige infraestrutura adicional).
- **Tool**: `MCP Client Tool` (via `n8n-nodes-mcp`, transporte stdio),
expondo a tool `agendar` do `gti-scheduling-mcp` (comando: `node
dist/index.js`, variáveis de ambiente = credenciais da clínica,
vindas de uma Credential do n8n — nunca hardcoded no node/JSON do
workflow).
4. **Respond to Webhook** — modo texto simples; devolve só a resposta em
linguagem natural produzida pelo AI Agent.
## Tratamento de erro
Quando `agendar` falha (`VALIDATION_ERROR`, `AUTHENTICATION_ERROR`,
`NOT_FOUND`, `ZPRO_API_ERROR`/`KLINGO_API_ERROR`, timeout, etc.), o MCP já
devolve uma resposta estruturada (`success:false` + código/mensagem) — o
AI Agent recebe isso como resultado da tool e decide, em linguagem
natural, como comunicar isso ao cliente (pedir os dados de novo, avisar
que vai encaminhar para um atendente humano, etc.), dentro do que o
system prompt permitir. Não há tratamento hard-coded por código de erro
neste workflow — isso fica deliberadamente a cargo do agente, para manter
o MVP simples.
## Risco de agendamento duplicado (mitigação no nível do workflow)
O `ZProAdapter`/`KlingoAdapter` já trata qualquer HTTP 2xx do provider
como sucesso operacional (sem risco de duplicidade por reinterpretação de
resposta ambígua — ver `docs/zpro-api-mapping.md`). O risco adicional
neste workflow é de outra natureza: o **próprio agente** decidir chamar a
tool `agendar` mais de uma vez na mesma conversa (ex.: o cliente confirma
duas vezes, ou o agente se confunde). Mitigação adotada para o MVP:
instrução explícita no system prompt para nunca chamar `agendar` mais de
uma vez por atendimento (`protocol`). Isto é uma mitigação por prompt, não
uma garantia determinística — se isso se mostrar insuficiente em uso real,
a evolução natural é adicionar uma marcação de "já agendado" no estado da
sessão, verificada por código antes de permitir uma nova chamada da tool.
## Fora de escopo (explicitamente adiado)
- Consulta de disponibilidade (`consultar_horarios`) — não implementada
para nenhum provider neste MVP (Z-PRO não suporta; Klingo tem lacuna de
mapeamento de resposta).
- Cancelamento/remarcação de agendamento.
- Transferência automática de fila/atendimento humano no Z-PRO após o
agendamento (padrão existente no fluxo de referência via
`updateticketinfo`) — pode ser adicionado depois, não é necessário para
o MVP funcionar.
- Suporte a múltiplas clínicas com providers diferentes atendidas por uma
única execução/webhook compartilhado — cada clínica é uma cópia
dedicada do workflow.
- Memória de conversa durável entre restarts do n8n (Postgres/Redis Chat
Memory).
## Testes
Este workflow vive no n8n, fora do repositório `gti-scheduling-mcp` — não
há testes automatizados de código aqui. A validação é funcional/manual:
- Testar o fluxo completo com uma conversa real (ou simulada via chamada
direta ao webhook) contra o Z-PRO, com dados fictícios, seguindo a mesma
disciplina de segurança contra duplicidade já usada nos testes manuais
do `ZProAdapter` (execução única, verificação visual no Z-PRO antes de
qualquer nova tentativa).
- Confirmar que o `Respond to Webhook` devolve texto puro, sem envelope
JSON, compatível com o que o Z-PRO espera exibir ao cliente.
+29
View File
@@ -0,0 +1,29 @@
# Configuração do provider de agendamento
# Valores suportados: "zpro" | "klingo"
SCHEDULING_PROVIDER=zpro
# ---------------------------------------------------------------------------
# Z-PRO — exigidas somente quando SCHEDULING_PROVIDER=zpro
# ---------------------------------------------------------------------------
# URL base da API do Z-PRO (obrigatória)
ZPRO_BASE_URL=
# Token de autenticação da API do Z-PRO (obrigatória)
ZPRO_API_TOKEN=
# Identificador da API (ApiID) usado nas rotas externas do Z-PRO (obrigatória)
ZPRO_API_ID=
# Identificador da instância no Z-PRO (opcional, mantido por compatibilidade)
ZPRO_INSTANCE_ID=
# ---------------------------------------------------------------------------
# Klingo — exigidas somente quando SCHEDULING_PROVIDER=klingo
# ---------------------------------------------------------------------------
# URL base da API do Klingo (obrigatória)
KLINGO_BASE_URL=
# Token de aplicação do Klingo, enviado no header X-APP-TOKEN (obrigatória)
KLINGO_APP_TOKEN=
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
.env
*.log
+7
View File
@@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 80,
"tabWidth": 2
}
+276
View File
@@ -0,0 +1,276 @@
# gti-scheduling-mcp
## Objetivo do projeto
`gti-scheduling-mcp` é um **MCP Server** responsável **exclusivamente** por
realizar agendamentos em sistemas de clínicas.
O MCP nunca conhece detalhes de nenhuma API específica. Toda a integração
com um sistema de clínica (Z-Pro, Klingo, SysLife, ClinicAll, etc.) é
responsabilidade exclusiva do respectivo **Adapter**, que implementa uma
interface comum e genérica.
Neste MVP existem apenas duas ferramentas (tools) expostas via MCP:
- `consultar_horarios` — consulta horários disponíveis para agendamento.
- `agendar` — realiza um agendamento em um horário disponível.
Nenhuma outra ferramenta, regra de negócio, integração HTTP, banco de
dados ou IA é implementada nesta etapa. Este repositório representa
apenas o esqueleto inicial do projeto.
## Estrutura das pastas
```
src/
adapters/
scheduling-adapter.interface.ts # Contrato comum a todos os Adapters
adapter-factory.ts # Fábrica que resolve o Adapter a usar
zpro/
zpro.adapter.ts # Adapter do sistema Z-Pro
zpro.types.ts # Tipos internos (corpo da requisição verificado)
zpro.mapper.ts # Mapeamento genérico -> corpo da requisição Z-PRO
zpro-http-client.ts # Cliente HTTP (POST appointment/create)
zpro.errors.ts # Erros tipados e classificação de status HTTP
klingo/
klingo.adapter.ts # Adapter do sistema Klingo
klingo.types.ts # Tipos internos (schemas verificados no Swagger)
klingo.mapper.ts # Mapeamento genérico <-> requisições/respostas Klingo
klingo-http-client.ts # Cliente HTTP (identificar, horarios, reservar)
klingo.errors.ts # Erros tipados e classificação de status HTTP
tools/
consultar-horarios.tool.ts # Definição da tool "consultar_horarios"
agendar.tool.ts # Definição da tool "agendar"
mcp-response.ts # Helpers de resposta MCP (sucesso/erro)
server/
mcp.server.ts # Criação/registro do servidor MCP
types/
scheduling.types.ts # Tipos genéricos de domínio
config/
env.config.ts # Leitura/validação de variáveis de ambiente
config.types.ts # Tipos de configuração (Z-Pro e genérico)
diagnostics/
sanitize.ts # Sanitização recursiva de respostas
analyze.ts # Identificação da estrutura de respostas
client.ts # Cliente HTTP mínimo (GET + timeout)
response.ts # Tratamento de resposta JSON/não JSON
index.ts # Ponto de entrada da aplicação
scripts/
diagnose-zpro-list.ts # CLI temporário de descoberta do contrato da API
test/
config.test.ts # Testes da validação de configuração
adapter-factory.test.ts # Testes da AdapterFactory
tools.test.ts # Testes do tratamento de erro das ferramentas
zpro-adapter.test.ts # Testes do ZProAdapter real (mocks de fetch)
klingo-adapter.test.ts # Testes do KlingoAdapter real (mocks de fetch)
sanitize.test.ts # Testes da sanitização recursiva
analyze.test.ts # Testes da identificação de estrutura
diagnose-client.test.ts # Testes do client de diagnóstico e respostas
docs/
zpro-api-mapping.md # Contrato verificado da API do Z-PRO
klingo-openapi.json # Swagger/OpenAPI público da API do Klingo
```
## Fluxo do sistema
1. Um cliente MCP invoca uma das ferramentas registradas
(`consultar_horarios` ou `agendar`).
2. A ferramenta (`tools/`) recebe a chamada, mas **não** contém regra de
negócio — sua responsabilidade é apenas definir o contrato de entrada
e delegar a execução.
3. A execução é delegada ao Adapter apropriado, obtido através da
`adapter-factory.ts`, que recebe a configuração validada e resolve o
Adapter correto para o provider configurado.
4. O Adapter (ex.: `ZProAdapter`) é responsável por toda a comunicação
com o sistema real da clínica, respeitando a interface
`SchedulingAdapter`.
5. O resultado retorna ao cliente MCP no formato genérico definido em
`types/scheduling.types.ts`.
As ferramentas MCP capturam qualquer erro lançado pelo Adapter e devolvem
uma resposta estruturada (`success: false` + código/mensagem), sem
encerrar o servidor.
## Status do ZProAdapter
- **`consultarHorarios()`**: decisão de produto — o Z-PRO não expõe
nenhum endpoint de disponibilidade de horários (ver
`docs/zpro-api-mapping.md`). O método sempre lança um erro claro
(`ZProApiError` com código `NOT_SUPPORTED`); não há solução
alternativa implementada (não se tenta inferir disponibilidade a
partir da lista de agendamentos existentes).
- **`agendar()`**: a chamada HTTP real (`POST appointment/create`) é
implementada de ponta a ponta — autenticação, URL, corpo da requisição
e classificação de erros HTTP (401/403/404/400/422/500, timeout, erro
de rede).
O **mapeamento da resposta de sucesso** foi verificado por um teste
manual único e controlado em 09/08/2026, contra uma instância real do
Z-PRO (ver `docs/zpro-api-mapping.md`, seção "Atualização — resposta
real de sucesso capturada"). Quando o corpo bate com o envelope
observado (`{success: true, data: {id, startAt, endAt, status}}`), o
`Appointment` é construído com o `id` **real** retornado pela API — não
é mais um placeholder.
Mesmo assim, **qualquer HTTP 2xx continua sendo tratado como sucesso
operacional** — o adapter **não lança erro** quando o corpo foge desse
envelope (formato diferente, texto simples, vazio, ou JSON malformado).
Só existe um exemplo real capturado até agora (só cobre o caminho de
sucesso com `status: "pending"`); reportar como falha uma operação que
o provider aceitou (e que pode já ter sido persistida) induziria um
consumidor (agente/n8n) a repeti-la, arriscando um agendamento
duplicado. Nesse caso, `agendar()` retorna um `Appointment` construído
apenas com os dados já conhecidos do próprio input, com `id` ausente e
`responseMappingPending: true`. Consumidores devem tratar
`responseMappingPending: true` como "aceito, detalhes incompletos" —
**nunca** como motivo para reenviar a operação. Apenas respostas HTTP
fora do intervalo 2xx continuam classificadas como erro
(401/403/404/400/422/500, timeout, erro de rede).
Opcionalmente, `new ZProAdapter(config, { onResponseMetadata })` aceita
um callback de diagnóstico chamado após cada resposta do Z-PRO, com
metadados seguros (status, content-type, presença de corpo, formato
aparente) — nunca o corpo, o token ou dados do paciente. Sem esse
callback, nada é registrado (por padrão, para nunca escrever em stdout
e corromper o transporte stdio do MCP).
## Status do KlingoAdapter
Baseado inteiramente em `docs/klingo-openapi.json` (Swagger público da API
do Klingo — bem mais completo que o do Z-PRO: 41 endpoints documentados,
incluindo consulta de disponibilidade real).
- **`consultarHorarios()`**: chama `GET /agenda/horarios` de verdade,
autenticado por `X-APP-TOKEN`. A API confirma a estrutura externa da
resposta (`procedimento`, `horarios[]` com `id`/`data`/`profissional`/
`unidade`), mas **o formato interno de cada horário específico não está
documentado** no Swagger — é descrito apenas como um objeto genérico,
sem `properties`/`items`. Sem essa informação não é possível montar
`AvailableSlot.startDateTime`/`endDateTime` reais sem inventar nomes de
campo. Por isso este método **sempre lança** `KlingoApiError` com código
`SLOT_MAPPING_UNAVAILABLE` quando a API responde com sucesso — é uma
lacuna de contrato conhecida, não uma falha técnica, e não deve ser
tratada como motivo para retry. Assim que uma resposta real de
`GET /agenda/horarios` for capturada, `klingo.adapter.ts` deve ganhar o
mapeamento completo.
- **`agendar()`**: implementado de ponta a ponta.
1. Identifica o paciente via `POST /paciente/identificar`
(`X-APP-TOKEN`), usando `patient.phone` + `patient.document` (como
CPF) — ou `apenas_telefone: true` quando não há `document`. Não
cria nenhum dado no Klingo, então erros aqui (inclusive resposta 2xx
sem `access_token` válido) são reportados normalmente, sem risco de
duplicidade.
2. Reserva o horário via `POST /agenda/reservar`, autenticado pelo
Bearer de sessão obtido no passo anterior, enviando `procedimento`
(de `input.specialtyId`) e `id` (de `input.slotId`) — ambos
obrigatórios pela API, validados antes de qualquer chamada de rede.
Envia também `confirmado` preenchido, para que a marcação já seja
feita como confirmada em vez de uma reserva temporária de 10 minutos
— comportamento documentado literalmente no Swagger
("Se for passado um texto, a marcação já irá ser feita como
confirmada").
3. O schema de sucesso de `reservar` (`reserva`) **é documentado**
(`id`, `data`, `hora`, `profissional`) — diferente do Z-PRO, a
resposta é mapeada de verdade para o `Appointment` genérico, com
`status: "confirmed"`. Mesmo assim, **qualquer HTTP 2xx continua
sendo tratado como sucesso operacional** mesmo que o corpo fuja do
schema documentado (JSON sem `id` numérico, corpo vazio, texto,
JSON malformado) — mesma proteção contra duplicidade do ZProAdapter:
nesse caso, retorna um `Appointment` com `responseMappingPending: true`
em vez de lançar erro.
Variáveis de ambiente: apenas `KLINGO_BASE_URL` e `KLINGO_APP_TOKEN`
(ver seção Configuração). O token de sessão do paciente não é uma
variável de ambiente — é obtido em tempo de execução, por paciente, e
descartado a cada chamada (sem cache, por simplicidade neste MVP).
## Configuração
A configuração é lida exclusivamente por variáveis de ambiente (módulo
`src/config/env.config.ts`), conforme o arquivo de exemplo `.env.example`.
Cada provider só exige suas próprias variáveis — as do provider não
selecionado em `SCHEDULING_PROVIDER` não são exigidas.
| Variável | Obrigatória | Descrição |
| --------------------- | ------------------------------------ | ----------------------------------------------------------------- |
| `SCHEDULING_PROVIDER` | Não (padrão `zpro`) | Provider de agendamento (`zpro` \| `klingo`) |
| `ZPRO_BASE_URL` | Sim, se `SCHEDULING_PROVIDER=zpro` | URL base da API do Z-PRO |
| `ZPRO_API_TOKEN` | Sim, se `SCHEDULING_PROVIDER=zpro` | Token de autenticação da API do Z-PRO |
| `ZPRO_API_ID` | Sim, se `SCHEDULING_PROVIDER=zpro` | Identificador da API (ApiID) usado nas rotas externas |
| `ZPRO_INSTANCE_ID` | Não | Identificador da instância no Z-PRO (mantido por compatibilidade) |
| `KLINGO_BASE_URL` | Sim, se `SCHEDULING_PROVIDER=klingo` | URL base da API do Klingo |
| `KLINGO_APP_TOKEN` | Sim, se `SCHEDULING_PROVIDER=klingo` | Token de aplicação do Klingo, enviado no header `X-APP-TOKEN` |
O token de **sessão do paciente** do Klingo (`Authorization: Bearer`,
obtido via `/externo/login` ou `/paciente/identificar`) não é uma
variável de ambiente — não é um segredo fixo de configuração, e sim algo
obtido em tempo de execução por paciente. Ver `docs/klingo-openapi.json`.
Com configuração inválida, a aplicação encerra a inicialização com uma
mensagem clara, sem expor nenhum token em mensagens de erro ou logs.
Nunca crie um arquivo `.env` real com credenciais; o `.env` está no
`.gitignore`.
## Script de diagnóstico da API Z-PRO
O script `scripts/diagnose-zpro-list.ts` consulta apenas o endpoint de
**listagem** de agendamentos do Z-PRO (GET) para descobrir o contrato real
da resposta, pois a coleção pública do Postman não possui exemplos de
resposta. Veja `docs/zpro-api-mapping.md`.
```bash
npm run diagnose:zpro:list
```
O script:
- faz **uma única** requisição `GET` em
`{ZPRO_BASE_URL}/v2/api/external/{ZPRO_API_ID}/appointment/list`
com `page=1` e `limit=5` (parâmetros conservadores);
- envia `Authorization: Bearer <token>` e `Accept: application/json`;
- usa timeout de 15 segundos e **não** executa retry;
- **não** chama `appointment/create` nem modifica dados;
- imprime informações da requisição (URL sem token, status, content-type,
duração), a resposta sanitizada e um resumo estrutural;
- nunca imprime token, stack trace ou dados pessoais completos dos
pacientes (valores sensíveis são mascarados com `[REDACTED]`).
Em caso de falha (variável ausente, autenticação, ApiID inválido, timeout,
rede, resposta não JSON ou status HTTP fora de 2xx), o processo encerra
com código de saída 1.
## Como adicionar um novo Adapter
1. Criar uma nova pasta em `src/adapters/<nome-do-sistema>/`
(ex.: `src/adapters/klingo/`).
2. Criar a classe do Adapter (ex.: `klingo.adapter.ts`) implementando a
interface `SchedulingAdapter`, definida em
`scheduling-adapter.interface.ts`.
3. Implementar os métodos `consultarHorarios()` e `agendar()`, traduzindo
os dados específicos do sistema para os tipos genéricos definidos em
`types/scheduling.types.ts`.
4. Atualizar `adapter-factory.ts` para que o novo Adapter possa ser
selecionado quando necessário.
5. **Nunca** expor detalhes do sistema específico (nomes de campos,
endpoints, formatos próprios, etc.) fora do próprio Adapter.
## Scripts disponíveis
```bash
npm run dev # Executa o servidor em modo desenvolvimento (tsx)
npm run build # Compila o projeto TypeScript para dist/
npm run start # Executa o projeto já compilado
npm run test # Executa os testes unitários (node:test via tsx)
npm run diagnose:zpro:list # Consulta o endpoint de listagem do Z-PRO (leitura)
npm run lint # Executa o ESLint
npm run format # Formata o código com Prettier
```
File diff suppressed because it is too large Load Diff
+237
View File
@@ -0,0 +1,237 @@
# Mapeamento da API Z-PRO — pasta "Agendamentos e Lembretes"
> Este documento contém **apenas** informações verificadas diretamente na
> coleção pública do Postman em
> https://www.postman.com/comunidade-zdg/z-pro/folder/ktt2uon/agendamentos-e-lembretes
> (coleção "Z-PRO v4.x.x.x", pasta "📅 Agendamentos e Lembretes").
> Nenhum valor de token, header sensível ou segredo real foi copiado.
>
> **Status: análise incompleta.** A integração real (`ZProAdapter`) **não foi
> implementada** nesta etapa porque partes essenciais do contrato não estão
> disponíveis na coleção. Veja a seção "O que não foi possível verificar".
## Itens existentes na pasta
A pasta contém 10 requests. Apenas os relacionados ao escopo atual
(consultar agendamentos / criar agendamento) foram analisados em detalhe;
os demais foram apenas identificados para não serem confundidos com o
escopo.
| Nome | Método | Path | No escopo atual? |
| ---------------------- | ------ | ----------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| AppointmentCreate | POST | `/v2/api/external/{{ApiID}}/appointment/create` | ✅ sim |
| AppointmentList | GET | `/v2/api/external/{{ApiID}}/appointment/list` | ✅ sim (única consulta existente — ver ressalva abaixo) |
| AppointmentShow | GET | `/v2/api/external/{{ApiID}}/appointment/show/{{id}}` | fora do escopo pedido (não é "consultar horários/agendamentos disponíveis" em lista) |
| AppointmentUpdate | POST | `/v2/api/external/{{ApiID}}/appointment/update/{{id}}` | ❌ não (remarcação — explicitamente fora do escopo) |
| AppointmentDelete | POST | `/v2/api/external/{{ApiID}}/appointment/delete/{{id}}` | ❌ não (cancelamento — explicitamente fora do escopo) |
| ScheduleReminderCreate | POST | `/v2/api/external/{{ApiID}}/scheduleReminder/create` | ❌ não (lembretes — explicitamente fora do escopo) |
| ScheduleReminderList | GET | `/v2/api/external/{{ApiID}}/scheduleReminder/list` | ❌ não |
| ScheduleReminderToggle | POST | `/v2/api/external/{{ApiID}}/scheduleReminder/toggle/{{id}}` | ❌ não |
| ScheduleReminderUpdate | POST | `/v2/api/external/{{ApiID}}/scheduleReminder/update/{{id}}` | ❌ não |
| ScheduleReminderDelete | POST | `/v2/api/external/{{ApiID}}/scheduleReminder/delete/{{id}}` | ❌ não |
**Ressalva importante:** a pasta **não possui nenhum endpoint de "horários
disponíveis"** (busca de vagas livres/slots). O único endpoint de consulta
relacionado a agendamentos é `AppointmentList`, que **lista agendamentos já
existentes** (filtráveis por status `pending | confirmed | cancelled |
completed`), não horários livres para agendar. Ver detalhes na seção de
ambiguidades.
## Autenticação
- Tipo: **Bearer Token** (`auth.type = "bearer"`), definido tanto no nível
da coleção quanto em cada request individual.
- O token é enviado automaticamente pelo Postman no header
`Authorization: Bearer <token>`.
- Variável de coleção que armazena o token: `BearerToken` (valor de exemplo
na coleção: `your_token_here` — placeholder, não é um token real).
- Não há chave de API adicional em header (ex.: `X-API-Key`) documentada
nestes requests — apenas o Bearer token.
## Variáveis de coleção relevantes
| Variável | Uso verificado |
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `BaseUrl` | Host base da API (ex.: `https://{{BaseUrl}}/v2/api/external/...`) |
| `ApiID` | Segmento obrigatório da URL em **todos** os endpoints desta pasta: `/v2/api/external/{{ApiID}}/...`. Não há descrição do que ele representa (ex.: id da instância/empresa) além do nome da variável. |
| `BearerToken` | Token usado na autenticação Bearer |
| `SuperAdminToken` | Não utilizado em nenhum request desta pasta |
**Achado relevante para configuração:** o `ApiID` é parte obrigatória da
URL de todo endpoint. A configuração atual do projeto
(`src/config/config.types.ts`, `src/config/env.config.ts`) já prevê
`ZPRO_INSTANCE_ID` como **opcional**. Se `ApiID` corresponder a esse campo,
ele precisa passar a ser **obrigatório** — mas isso não deve ser alterado
sem confirmação, pois não há descrição na coleção que confirme a
equivalência semântica entre `ApiID` e "instância".
## AppointmentCreate (criar agendamento)
- Método: `POST`
- URL: `https://{{BaseUrl}}/v2/api/external/{{ApiID}}/appointment/create`
- Auth: Bearer (herdado da coleção)
- Headers explícitos no request: nenhum além dos automáticos do Postman
(Bearer + `Content-Type: application/json`, implícito pelo modo de body
`raw`/`json`)
- Query params: nenhum
- Corpo da requisição (JSON, `dataMode: raw`, exemplo salvo na coleção):
```json
{
"title": "Reunião de Vendas",
"description": "Reunião sobre proposta comercial",
"contactId": "{{contactId}}",
"contactName": "João Silva",
"contactPhone": "5511999999999",
"whatsappId": "{{whatsappId}}",
"startAt": "2026-04-15T14:00:00.000Z",
"endAt": "2026-04-15T15:00:00.000Z",
"status": "pending",
"notes": "Levar proposta impressa"
}
```
- Campos identificados no exemplo: `title`, `description`, `contactId`,
`contactName`, `contactPhone`, `whatsappId`, `startAt`, `endAt`,
`status`, `notes`.
- **Não há indicação, na coleção, de quais desses campos são obrigatórios
versus opcionais** (o Postman não declara isso fora do exemplo).
- Formato de sucesso, formato de erro e o campo que identifica o
agendamento criado (`id`, `_id`, `appointment.id`, etc.): **não
verificados** — não há nenhuma resposta de exemplo salva neste request na
coleção (`responses_order: []`).
## AppointmentList (única consulta existente no escopo)
- Método: `GET`
- URL: `https://{{BaseUrl}}/v2/api/external/{{ApiID}}/appointment/list`
- Auth: Bearer (herdado da coleção)
- Query params verificados:
| Parâmetro | Exemplo | Habilitado por padrão | Descrição na coleção |
| ----------- | -------------------------- | --------------------- | --------------------------------------------------- |
| `page` | `1` | sim | — |
| `limit` | `20` | sim | — |
| `status` | `pending` | sim | `pending \| confirmed \| cancelled \| completed` |
| `startFrom` | `2026-04-01T00:00:00.000Z` | **não** (disabled) | Filtrar agendamentos a partir desta data (ISO 8601) |
| `startTo` | `2026-04-30T23:59:59.000Z` | **não** (disabled) | Filtrar agendamentos até esta data (ISO 8601) |
| `search` | `""` | **não** (disabled) | Busca por título ou nome do contato |
- Corpo da requisição: nenhum (GET).
- Formato de sucesso (envelope, paginação, forma de cada item da lista) e
formato de erro: **não verificados** — sem resposta de exemplo salva.
## O que não foi possível verificar
1. **Formato de sucesso de qualquer endpoint** (envelope da resposta,
nomes de campos, se há paginação e como ela é representada).
2. **Formato de erro de qualquer endpoint** (estrutura do corpo de erro,
nomes de campos como `message`/`error`/`code`).
3. **Identificador retornado após a criação** do agendamento (nome do
campo, ex.: `id` vs `_id` vs `appointment.id`).
4. **Conceito de "horário disponível"**: a pasta não expõe nenhum endpoint
de disponibilidade/slots livres. `AppointmentList` lista agendamentos
já existentes (ocupados), não horários livres. Não é seguro tratar a
ausência de um agendamento como "horário disponível" — isso seria
inventar uma regra de negócio não documentada.
5. **Relação entre `slotId` (tipo genérico do projeto,
`CreateAppointmentInput.slotId`) e a API Z-PRO**: `AppointmentCreate`
não recebe um identificador de horário/slot — o agendamento é criado
diretamente por `startAt`/`endAt`. Não há, na coleção, nenhum conceito
de "slot" com identificador próprio.
6. **Campos obrigatórios vs. opcionais** no corpo de `AppointmentCreate`
(a coleção só mostra um exemplo, sem schema declarado).
7. **Semântica exata de `ApiID`** (é o identificador da instância? da
empresa? do WhatsApp conectado?) — não há descrição na coleção.
## O que é necessário para prosseguir com a integração real
Qualquer um dos itens abaixo destravaria a implementação:
- Um **exemplo de resposta salvo** (Postman "Save Response" / "Add
Example") para `AppointmentCreate` (sucesso e, se possível, erro) e para
`AppointmentList` (sucesso).
- Confirmação explícita de que **não existe** endpoint de horários/slots
disponíveis na API Z-PRO (para então documentar oficialmente que
`consultarHorarios()` não tem correspondência 1:1 e decidir, com o
responsável do produto, qual comportamento é aceitável — por exemplo,
reinterpretar o escopo, ou aguardar um endpoint futuro).
- Documentação (Swagger/OpenAPI, README da API, ou outra coleção) que
descreva o schema de resposta dos dois endpoints.
- Confirmação do significado e da obrigatoriedade de `ApiID`.
## Decisão tomada nesta etapa
Como os contratos de resposta não puderam ser verificados, o
`ZProAdapter` **não foi alterado** — permanece lançando `Not implemented`,
exatamente como estava antes desta análise, para não implementar por
suposição. `npm run build`, `npm run lint` e `npm test` continuam
passando sem alterações de código.
## Atualização — resposta real de sucesso capturada (teste manual controlado)
Em 09/08/2026, foi executado um teste manual único e controlado de
`POST /v2/api/external/{ApiID}/appointment/create`, contra uma instância
real do Z-PRO, com dados fictícios (`TESTE-MCP-2026-04-20-001`). O
agendamento foi criado de fato (confirmado visualmente na interface do
Z-PRO) e a resposta HTTP foi capturada:
- **Content-Type**: `application/json; charset=utf-8`
- **Corpo bruto** (dados fictícios, sem nenhuma credencial):
```json
{
"success": true,
"data": {
"source": "manual",
"checkInStatus": "none",
"id": 2,
"title": "TESTE-MCP-2026-04-20-001",
"description": null,
"contactId": null,
"contactName": "Paciente Teste MCP",
"contactPhone": "5511900000000",
"whatsappId": null,
"tenantId": 4,
"startAt": "2026-04-20T14:00:00.000Z",
"endAt": "2026-04-20T14:30:00.000Z",
"status": "pending",
"notes": null,
"updatedAt": "2026-08-09T20:32:06.426Z",
"createdAt": "2026-08-09T20:32:06.426Z",
"bookingPageId": null,
"bookingEventTypeId": null,
"bookingProfessionalId": null,
"userId": null,
"location": null,
"meetingLink": null,
"formAnswers": null,
"cancelToken": null,
"googleEventId": null,
"checkInSentAt": null
}
}
```
**O que isso confirma:**
- Envelope de sucesso: `{ success: boolean, data: {...} }`.
- Identificador do agendamento criado: `data.id`, **numérico** (não
`_id`, não `appointment.id`).
- `data.status` reflete o mesmo valor enviado na requisição (`"pending"`)
— **não temos, ainda, um exemplo real de `"confirmed"` ou
`"cancelled"` retornados pela API**; o mapeamento desses dois valores
no `ZProAdapter` é feito por analogia com o valor documentado no
request (`AppointmentList`'s filtro de `status`), não por observação
direta.
- `data.startAt`/`data.endAt` são ecoados exatamente como enviados.
- `data.contactId` continua `null` quando não enviado no request (não
inventa um contato).
**O que continua não verificado:** o formato de erro (4xx/5xx) do
endpoint (esse teste só cobriu o caminho de sucesso), e os valores reais
de `data.status` para `"confirmed"`/`"cancelled"`. O `ZProAdapter`
continua tratando **qualquer HTTP 2xx** como sucesso mesmo que o corpo
não bata com este envelope exato (ver `zpro.mapper.ts`), como proteção
contra duplicidade caso a API mude de formato em algum caso não coberto
por este teste único.
+25
View File
@@ -0,0 +1,25 @@
// eslint.config.js
//
// Configuração do ESLint para o projeto, utilizando as regras
// recomendadas para TypeScript.
import js from "@eslint/js";
import tseslint from "typescript-eslint";
export default tseslint.config(
js.configs.recommended,
...tseslint.configs.recommended,
{
ignores: ["dist/**"],
},
{
rules: {
// Permite prefixar parâmetros intencionalmente não utilizados com "_"
// (útil em stubs de Adapter que ainda não implementam a lógica real).
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_" },
],
},
},
);
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
{
"name": "gti-scheduling-mcp",
"version": "0.1.0",
"description": "MCP Server responsável por realizar agendamentos em sistemas de clínicas, através de Adapters específicos para cada sistema.",
"type": "module",
"main": "dist/index.js",
"scripts": {
"dev": "tsx src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"test": "tsx --test \"test/**/*.test.ts\"",
"diagnose:zpro:list": "tsx scripts/diagnose-zpro-list.ts",
"lint": "eslint .",
"format": "prettier --write ."
},
"keywords": [
"mcp",
"scheduling",
"clinics"
],
"license": "UNLICENSED",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.0",
"zod": "^3.24.2"
},
"devDependencies": {
"@eslint/js": "^9.20.0",
"@types/node": "^22.13.4",
"eslint": "^9.20.0",
"prettier": "^3.5.1",
"tsx": "^4.19.2",
"typescript": "^5.7.3",
"typescript-eslint": "^8.24.0"
}
}
@@ -0,0 +1,146 @@
/**
* scripts/diagnose-zpro-list.ts
*
* Script CLI temporário e seguro para descobrir o contrato real do
* endpoint:
*
* GET /v2/api/external/{ApiID}/appointment/list
*
* Faz apenas UMA requisição GET de leitura (page=1, limit=5), com timeout
* de 15s, sem retry e sem modificar nenhum dado. Imprime a resposta
* sanitizada e um resumo estrutural.
*
* O script nunca imprime token, header Authorization, stack trace ou
* dados pessoais completos dos pacientes.
*/
import { loadSchedulingConfig } from "../src/config/env.config.js";
import {
buildAppointmentListUrl,
fetchAppointmentList,
NetworkRequestError,
RequestTimeoutError,
} from "../src/diagnostics/client.js";
import { buildResponseReport } from "../src/diagnostics/response.js";
import { sanitizeText } from "../src/diagnostics/sanitize.js";
import type { StructureSummary } from "../src/diagnostics/analyze.js";
function list(values: string[]): string {
return values.length > 0 ? values.join(", ") : "(nenhum)";
}
function classifyHttpStatus(status: number): string {
if (status === 401 || status === 403) {
return `Erro de autenticação (status ${status}). Verifique ZPRO_API_TOKEN.`;
}
if (status === 404) {
return `ApiID inválido ou recurso não encontrado (status ${status}). Verifique ZPRO_API_ID.`;
}
return `Status HTTP ${status} (esperado 2xx).`;
}
function printStructureSummary(summary: StructureSummary): void {
console.log(`Tipo da raiz: ${summary.rootType}`);
if (summary.rootType === "array") {
console.log(`Quantidade de itens no array: ${summary.arrayItemCount}`);
console.log(`Chaves do primeiro item: ${list(summary.firstItemKeys)}`);
} else if (summary.rootType === "object") {
console.log(`Chaves na raiz: ${list(summary.rootKeys)}`);
}
console.log("Tipos dos campos:");
for (const [key, type] of Object.entries(summary.fieldTypes)) {
console.log(` ${key}: ${type}`);
}
console.log(
`Possíveis campos de paginação: ${list(summary.possiblePaginationFields)}`,
);
console.log(
`Possíveis campos de identificação: ${list(summary.possibleIdFields)}`,
);
console.log(`Possíveis campos de data: ${list(summary.possibleDateFields)}`);
console.log(
`Possíveis campos de status: ${list(summary.possibleStatusFields)}`,
);
}
async function main(): Promise<number> {
let config;
try {
config = loadSchedulingConfig();
} catch (error) {
const message =
error instanceof Error
? error.message
: "Falha ao carregar a configuração.";
console.error(message);
return 1;
}
const secrets = [config.apiToken];
let response;
try {
response = await fetchAppointmentList(config);
} catch (error) {
if (error instanceof RequestTimeoutError) {
console.error("Timeout ao consultar a API do Z-PRO (15s).");
} else if (error instanceof NetworkRequestError) {
console.error("Falha de rede ao consultar a API do Z-PRO.");
} else {
console.error("Erro inesperado ao consultar a API do Z-PRO.");
}
return 1;
}
console.log("=== Informações da requisição ===");
console.log("Método: GET");
console.log(`URL: ${buildAppointmentListUrl(config)}`);
console.log(`Status: ${response.status}`);
console.log(`Content-Type: ${response.contentType ?? "(ausente)"}`);
console.log(`Duração: ${response.durationMs}ms`);
if (response.status < 200 || response.status >= 300) {
console.log("");
console.log("=== Erro HTTP ===");
console.log(classifyHttpStatus(response.status));
if (response.bodyText) {
console.log("");
console.log("Corpo da resposta (primeiros 500 caracteres, sanitizado):");
console.log(sanitizeText(response.bodyText, secrets));
}
return 1;
}
const report = buildResponseReport(response.bodyText, secrets);
if (!report.isJson) {
console.log("");
console.log("=== A resposta não é JSON ===");
console.log("Conteúdo (primeiros 500 caracteres, sanitizado):");
console.log(report.sanitizedText);
return 1;
}
console.log("");
console.log("=== Estrutura da resposta ===");
console.log(report.sanitizedText);
if (report.structure) {
console.log("");
console.log("=== Resumo estrutural ===");
printStructureSummary(report.structure);
}
return 0;
}
main()
.then((code) => {
process.exit(code);
})
.catch(() => {
process.exit(1);
});
@@ -0,0 +1,30 @@
/**
* adapters/adapter-factory.ts
*
* Fábrica responsável por fornecer a instância correta de SchedulingAdapter
* com base na configuração de provider validada.
*
* Providers suportados: "zpro" e "klingo". Qualquer outro valor é
* rejeitado. Esta função não lê `process.env` diretamente: a configuração
* é recebida já validada pelo módulo de configuração.
*/
import type { SchedulingProviderConfig } from "../config/config.types.js";
import type { SchedulingAdapter } from "./scheduling-adapter.interface.js";
import { ZProAdapter } from "./zpro/zpro.adapter.js";
import { KlingoAdapter } from "./klingo/klingo.adapter.js";
export function createSchedulingAdapter(
config: SchedulingProviderConfig,
): SchedulingAdapter {
if (config.provider === "zpro") {
return new ZProAdapter(config);
}
if (config.provider === "klingo") {
return new KlingoAdapter(config);
}
throw new Error(
`Provider de agendamento não suportado: "${(config as { provider: string }).provider}".`,
);
}
@@ -0,0 +1,151 @@
/**
* adapters/klingo/klingo-http-client.ts
*
* Cliente HTTP mínimo do KlingoAdapter para os três endpoints usados:
* - POST /paciente/identificar (X-APP-TOKEN)
* - GET /agenda/horarios (X-APP-TOKEN)
* - POST /agenda/reservar (Authorization: Bearer, sessão do paciente)
*
* Usa o `fetch` nativo do Node.js. Timeout via AbortController. Nunca inclui
* token/CPF/telefone em mensagens de erro. Não faz retry. Mesmo padrão já
* usado em `src/adapters/zpro/zpro-http-client.ts`.
*/
import type { KlingoConfig } from "../../config/config.types.js";
import type { KlingoHorariosQuery } from "./klingo.types.js";
import { KlingoApiError } from "./klingo.errors.js";
/** Timeout padrão das requisições ao Klingo (15 segundos). */
export const DEFAULT_TIMEOUT_MS = 15_000;
/** Resultado bruto de uma chamada HTTP ao Klingo. */
export interface KlingoHttpResponse {
status: number;
contentType: string | null;
bodyText: string;
}
export interface KlingoHttpOptions {
timeoutMs?: number;
fetchImpl?: typeof fetch;
}
function isAbortError(error: unknown): boolean {
return error instanceof Error && error.name === "AbortError";
}
function buildUrl(config: Pick<KlingoConfig, "baseUrl">, path: string): string {
const baseUrl = config.baseUrl.replace(/\/+$/, "");
return `${baseUrl}${path}`;
}
/** Executa uma requisição HTTP ao Klingo, normalizando timeout e erro de rede. */
async function executeRequest(
url: string,
init: RequestInit,
options: KlingoHttpOptions,
context: string,
): Promise<KlingoHttpResponse> {
const { timeoutMs = DEFAULT_TIMEOUT_MS, fetchImpl = fetch } = options;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetchImpl(url, {
...init,
signal: controller.signal,
});
const bodyText = await response.text();
return {
status: response.status,
contentType: response.headers.get("content-type"),
bodyText,
};
} catch (error) {
if (isAbortError(error)) {
throw new KlingoApiError(
"KLINGO_TIMEOUT",
`Tempo limite excedido ao ${context}.`,
);
}
throw new KlingoApiError(
"KLINGO_API_ERROR",
`Falha de rede ao ${context}.`,
);
} finally {
clearTimeout(timer);
}
}
/** `POST /paciente/identificar` — autenticado por X-APP-TOKEN. */
export async function identificarPaciente(
config: Pick<KlingoConfig, "baseUrl" | "appToken">,
body: { telefone: string; cpf?: string; apenas_telefone?: boolean },
options: KlingoHttpOptions = {},
): Promise<KlingoHttpResponse> {
return executeRequest(
buildUrl(config, "/paciente/identificar"),
{
method: "POST",
headers: {
"X-APP-TOKEN": config.appToken,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(body),
},
options,
"identificar o paciente no Klingo",
);
}
/** `GET /agenda/horarios` — autenticado por X-APP-TOKEN. */
export async function getHorarios(
config: Pick<KlingoConfig, "baseUrl" | "appToken">,
query: KlingoHorariosQuery,
options: KlingoHttpOptions = {},
): Promise<KlingoHttpResponse> {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value !== undefined) params.set(key, value);
}
const suffix = params.toString() ? `?${params.toString()}` : "";
return executeRequest(
buildUrl(config, `/agenda/horarios${suffix}`),
{
method: "GET",
headers: {
"X-APP-TOKEN": config.appToken,
Accept: "application/json",
},
},
options,
"consultar horários disponíveis no Klingo",
);
}
/** `POST /agenda/reservar` — autenticado pelo Bearer de sessão do paciente. */
export async function reservarHorario(
config: Pick<KlingoConfig, "baseUrl">,
accessToken: string,
body: Record<string, unknown>,
options: KlingoHttpOptions = {},
): Promise<KlingoHttpResponse> {
return executeRequest(
buildUrl(config, "/agenda/reservar"),
{
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(body),
},
options,
"reservar o horário no Klingo",
);
}
@@ -0,0 +1,147 @@
/**
* adapters/klingo/klingo.adapter.ts
*
* Adapter responsável pela integração com o sistema Klingo. Baseado
* inteiramente em `docs/klingo-openapi.json` (Swagger público da API).
*
* `consultarHorarios()`: chama `GET /agenda/horarios` de verdade
* (autenticado por `X-APP-TOKEN`). A API confirma a estrutura externa da
* resposta (`procedimento`, `horarios[]` com `id`/`data`/`profissional`/
* `unidade`), mas o formato interno de cada horário específico (a lista de
* horas dentro de cada bloco) NÃO está documentado no Swagger — é descrito
* apenas como um objeto genérico, sem `properties`/`items`. Sem essa
* informação não é possível montar `AvailableSlot.startDateTime`/
* `endDateTime` reais sem inventar nomes de campo. Por isso este método
* lança `KlingoApiError` com código `SLOT_MAPPING_UNAVAILABLE` sempre que a
* API responde com sucesso — é uma lacuna de contrato conhecida, não uma
* falha técnica. Ver `zpro-api-mapping.md`/discussão equivalente: assim que
* uma resposta real de `GET /agenda/horarios` for capturada, este método
* deve ganhar o mapeamento completo.
*
* `agendar()`: identifica o paciente por telefone
* (`POST /paciente/identificar`, também por `X-APP-TOKEN`) para obter um
* token de sessão, e então reserva o horário
* (`POST /agenda/reservar`, autenticado pelo Bearer de sessão), enviando o
* campo `confirmado` para que a marcação já seja feita como confirmada
* (comportamento documentado literalmente no Swagger). O schema de sucesso
* de `reservar` (`reserva`) É documentado (`id`, `data`, `hora`,
* `profissional`), então a resposta é mapeada de verdade — sem o placeholder
* `responseMappingPending` usado no Z-PRO, exceto no caso defensivo em que a
* API responde 2xx com um corpo que foge do schema documentado (ver
* `klingo.mapper.ts#buildPendingMappingAppointment`), pelo mesmo motivo já
* aplicado ao Z-PRO: nunca tratar um HTTP 2xx como erro operacional, para
* não induzir um consumidor a repetir a operação e criar um agendamento
* duplicado.
*/
import type { SchedulingAdapter } from "../scheduling-adapter.interface.js";
import type {
AvailableSlot,
Appointment,
QueryAvailableSlotsInput,
CreateAppointmentInput,
} from "../../types/scheduling.types.js";
import type { KlingoConfig } from "../../config/config.types.js";
import { KlingoApiError, classifyHttpErrorStatus } from "./klingo.errors.js";
import {
buildHorariosQuery,
buildIdentificarPacienteBody,
buildPendingMappingAppointment,
buildReservarBody,
buildAppointmentFromReserva,
extractAccessToken,
tryParseReservaResponse,
} from "./klingo.mapper.js";
import {
getHorarios,
identificarPaciente,
reservarHorario,
type KlingoHttpOptions,
} from "./klingo-http-client.js";
export class KlingoAdapter implements SchedulingAdapter {
private readonly config: KlingoConfig;
private readonly httpOptions: KlingoHttpOptions;
/**
* `options.fetchImpl`/`options.timeoutMs` permitem injetar um fetch fake
* e um timeout curto em testes, sem alterar o contrato usado pela
* AdapterFactory (`new KlingoAdapter(config)` continua válido).
*/
constructor(
config: KlingoConfig,
options: { fetchImpl?: typeof fetch; timeoutMs?: number } = {},
) {
this.config = config;
this.httpOptions = options;
}
async consultarHorarios(
input: QueryAvailableSlotsInput,
): Promise<AvailableSlot[]> {
const query = buildHorariosQuery(input);
const response = await getHorarios(this.config, query, this.httpOptions);
if (response.status < 200 || response.status >= 300) {
throw classifyHttpErrorStatus(
response.status,
"consultar horários disponíveis no Klingo",
);
}
// A API respondeu com sucesso, mas o schema documentado não detalha o
// formato interno de cada horário (ver docblock desta classe). Não
// inventamos nomes de campo — reportamos a lacuna de forma explícita.
throw new KlingoApiError(
"SLOT_MAPPING_UNAVAILABLE",
"A API do Klingo respondeu com sucesso, mas o formato dos horários individuais (data/hora de cada opção) não está documentado no Swagger público. É necessário capturar uma resposta real de GET /agenda/horarios para completar este mapeamento.",
);
}
async agendar(input: CreateAppointmentInput): Promise<Appointment> {
// 1) Identifica o paciente por telefone para obter um token de sessão.
// Esta etapa não cria nenhum dado no Klingo — sem risco de
// duplicidade — então é seguro lançar erro se algo sair fora do
// esperado, em vez de seguir adiante.
const identificarBody = buildIdentificarPacienteBody(input.patient);
const identificarResponse = await identificarPaciente(
this.config,
identificarBody,
this.httpOptions,
);
if (identificarResponse.status < 200 || identificarResponse.status >= 300) {
throw classifyHttpErrorStatus(
identificarResponse.status,
"identificar o paciente no Klingo",
);
}
const accessToken = extractAccessToken(identificarResponse.bodyText);
// 2) Reserva (e confirma) o horário. A partir daqui, HTTP 2xx é SEMPRE
// tratado como sucesso operacional, independentemente do corpo —
// mesma proteção contra duplicidade já usada no ZProAdapter.
const reservarBody = buildReservarBody(input);
const reservarResponse = await reservarHorario(
this.config,
accessToken,
reservarBody as unknown as Record<string, unknown>,
this.httpOptions,
);
if (reservarResponse.status < 200 || reservarResponse.status >= 300) {
throw classifyHttpErrorStatus(
reservarResponse.status,
"reservar o horário no Klingo",
);
}
const reserva = tryParseReservaResponse(reservarResponse.bodyText);
if (reserva) {
return buildAppointmentFromReserva(reserva, input);
}
// Corpo fora do schema documentado, mas HTTP 2xx: não é um erro.
return buildPendingMappingAppointment(input);
}
}
@@ -0,0 +1,74 @@
/**
* adapters/klingo/klingo.errors.ts
*
* Erros internos, previsíveis e tipados do KlingoAdapter.
*
* Nenhuma mensagem de erro expõe token, CPF, telefone, stack trace ou corpo
* bruto da resposta da API.
*
* - VALIDATION_ERROR -> HTTP 400 / 422, ou dado obrigatório
* ausente no input antes mesmo da chamada
* (ex.: telefone do paciente ausente)
* - AUTHENTICATION_ERROR -> HTTP 401 / 403 (inclui falha ao
* identificar/logar o paciente)
* - NOT_FOUND -> HTTP 404
* - KLINGO_API_ERROR -> demais erros HTTP (ex.: 500) e falha de rede
* - KLINGO_TIMEOUT -> requisição excedeu o tempo limite
* - SLOT_MAPPING_UNAVAILABLE -> a API respondeu com sucesso, mas o
* schema de resposta necessário para
* montar um AvailableSlot completo (data
* + horário) não está documentado no
* Swagger público (ver
* docs/klingo-openapi.json, definição
* `horariosAgenda.horarios[].horarios`).
* Não é um erro de rede/negócio: é uma
* lacuna de contrato conhecida. Não deve
* ser tratado como motivo para retry.
*/
export type KlingoErrorCode =
| "VALIDATION_ERROR"
| "AUTHENTICATION_ERROR"
| "NOT_FOUND"
| "KLINGO_API_ERROR"
| "KLINGO_TIMEOUT"
| "SLOT_MAPPING_UNAVAILABLE";
/** Erro tipado do KlingoAdapter, com um código previsível e mensagem curta. */
export class KlingoApiError extends Error {
readonly code: KlingoErrorCode;
constructor(code: KlingoErrorCode, message: string) {
super(message);
this.name = "KlingoApiError";
this.code = code;
}
}
/** Classifica um status HTTP de erro (fora do intervalo 2xx) em um KlingoApiError. */
export function classifyHttpErrorStatus(
status: number,
context: string,
): KlingoApiError {
if (status === 401 || status === 403) {
return new KlingoApiError(
"AUTHENTICATION_ERROR",
`Falha de autenticação na API do Klingo ao ${context} (status ${status}).`,
);
}
if (status === 404) {
return new KlingoApiError(
"NOT_FOUND",
`Recurso não encontrado na API do Klingo ao ${context} (status ${status}).`,
);
}
if (status === 400 || status === 422) {
return new KlingoApiError(
"VALIDATION_ERROR",
`A API do Klingo rejeitou os dados enviados ao ${context} (status ${status}).`,
);
}
return new KlingoApiError(
"KLINGO_API_ERROR",
`A API do Klingo retornou um erro inesperado ao ${context} (status ${status}).`,
);
}
@@ -0,0 +1,217 @@
/**
* adapters/klingo/klingo.mapper.ts
*
* Tradução entre os tipos genéricos de domínio (`types/scheduling.types.ts`)
* e os tipos internos do Klingo (`klingo.types.ts`), verificados contra
* `docs/klingo-openapi.json`.
*/
import type {
Appointment,
CreateAppointmentInput,
Patient,
QueryAvailableSlotsInput,
} from "../../types/scheduling.types.js";
import type {
KlingoHorariosQuery,
KlingoHorarioMarcarBody,
KlingoIdentificarPacienteBody,
KlingoReservaResponse,
} from "./klingo.types.js";
import { KlingoApiError } from "./klingo.errors.js";
/**
* Texto fixo enviado no campo `confirmado` de `horarioMarcar` para indicar
* que a marcação deve ser feita como confirmada (não apenas uma reserva
* temporária de 10 minutos). Comportamento documentado literalmente no
* Swagger: "Se for passado um texto, a marcação já irá ser feita como
* confirmada." Não há um valor específico exigido pela documentação, então
* usamos um marcador identificável para fins de auditoria.
*/
export const KLINGO_CONFIRMADO_MARKER = "Confirmado via gti-scheduling-mcp";
/**
* Monta o corpo de `POST /paciente/identificar` a partir do paciente.
*
* Exige `patient.phone` (obrigatório pela API). Quando `patient.document`
* está presente, é enviado como `cpf` (o tipo genérico já documenta
* `document` como "ex.: CPF"). Na ausência de `document`, usa
* `apenas_telefone: true`, conforme permitido pela API.
*
* Lança `VALIDATION_ERROR` se `patient.phone` estiver ausente — isso é
* verificado ANTES de qualquer chamada de rede.
*/
export function buildIdentificarPacienteBody(
patient: Patient,
): KlingoIdentificarPacienteBody {
if (!patient.phone?.trim()) {
throw new KlingoApiError(
"VALIDATION_ERROR",
"O telefone do paciente é obrigatório para identificação no Klingo.",
);
}
const body: KlingoIdentificarPacienteBody = { telefone: patient.phone };
if (patient.document?.trim()) {
body.cpf = patient.document;
} else {
body.apenas_telefone = true;
}
return body;
}
/** Monta os query params de `GET /agenda/horarios` a partir do input genérico. */
export function buildHorariosQuery(
input: QueryAvailableSlotsInput,
): KlingoHorariosQuery {
const query: KlingoHorariosQuery = {};
if (input.specialtyId) query.especialidade = input.specialtyId;
if (input.professionalId) query.crm = input.professionalId;
if (input.clinicId) query.cnes = input.clinicId;
if (input.startDate) query.inicio = input.startDate;
if (input.endDate) query.fim = input.endDate;
return query;
}
/**
* Monta o corpo de `POST /agenda/reservar` a partir do input de agendamento.
*
* Exige `input.slotId` (o `id` do horário, obtido via `consultarHorarios()`)
* e `input.specialtyId` (o código do procedimento associado a esse
* horário) — ambos obrigatórios pela API do Klingo (`horarioMarcar.id` e
* `horarioMarcar.procedimento`). Lança `VALIDATION_ERROR` se ausentes,
* ANTES de qualquer chamada de rede.
*/
export function buildReservarBody(
input: CreateAppointmentInput,
): KlingoHorarioMarcarBody {
if (!input.slotId?.trim()) {
throw new KlingoApiError(
"VALIDATION_ERROR",
"slotId é obrigatório para agendar no Klingo (obtido via consultarHorarios()).",
);
}
if (!input.specialtyId?.trim()) {
throw new KlingoApiError(
"VALIDATION_ERROR",
"specialtyId é obrigatório para agendar no Klingo (código do procedimento do horário selecionado).",
);
}
return {
procedimento: input.specialtyId,
id: input.slotId,
confirmado: KLINGO_CONFIRMADO_MARKER,
};
}
/**
* Constrói o `Appointment` genérico a partir da resposta de
* `POST /agenda/reservar` (definição `reserva`, totalmente documentada:
* `id`, `data`, `hora`, `profissional`). Diferente do Z-PRO, aqui o
* mapeamento de sucesso É verificado — nenhum campo é inventado.
*/
export function buildAppointmentFromReserva(
reserva: KlingoReservaResponse,
input: CreateAppointmentInput,
): Appointment {
return {
id: String(reserva.id),
slot: {
id: input.slotId,
startDateTime: input.startAt,
endDateTime: input.endAt,
professionalId: reserva.profissional?.id
? String(reserva.profissional.id)
: undefined,
professionalName: reserva.profissional?.nome,
specialtyId: input.specialtyId,
},
patient: input.patient,
status: "confirmed",
};
}
/**
* Constrói um `Appointment` "aceito, mapeamento pendente" apenas a partir
* do input — usado quando `POST /agenda/reservar` responde com HTTP 2xx,
* mas o corpo não pode ser interpretado como o `reserva` documentado (ex.:
* corpo vazio/não-JSON, ou sem um `id` numérico válido).
*
* Mesmo princípio de segurança já aplicado no ZProAdapter: reportar essa
* situação como erro induziria um consumidor a repetir a operação, com
* risco de agendamento duplicado. Nunca lê nenhum campo do corpo da
* resposta.
*/
export function buildPendingMappingAppointment(
input: CreateAppointmentInput,
): Appointment {
return {
slot: {
id: input.slotId,
startDateTime: input.startAt,
endDateTime: input.endAt,
specialtyId: input.specialtyId,
},
patient: input.patient,
status: "pending",
responseMappingPending: true,
};
}
/**
* Tenta interpretar o corpo de `POST /agenda/reservar` como o `reserva`
* documentado. Retorna `undefined` (nunca lança) quando o corpo não é JSON
* válido ou não contém um `id` numérico — nesse caso o chamador deve usar
* `buildPendingMappingAppointment`, nunca inventar os campos faltantes.
*/
export function tryParseReservaResponse(
bodyText: string,
): KlingoReservaResponse | undefined {
let parsed: unknown;
try {
parsed = JSON.parse(bodyText);
} catch {
return undefined;
}
if (
typeof parsed === "object" &&
parsed !== null &&
"id" in parsed &&
typeof (parsed as { id: unknown }).id === "number"
) {
return parsed as KlingoReservaResponse;
}
return undefined;
}
/**
* Extrai `access_token` do corpo de `POST /paciente/identificar` (definição
* `sessao`). Diferente da resposta de `reservar`, esta chamada NÃO cria
* nenhum dado no Klingo — ainda não há risco de duplicidade — então, ao
* contrário de `tryParseReservaResponse`, aqui é seguro lançar um erro
* claro (`AUTHENTICATION_ERROR`) quando o corpo não traz um token utilizável,
* em vez de seguir adiante com um valor inventado.
*/
export function extractAccessToken(bodyText: string): string {
let parsed: unknown;
try {
parsed = JSON.parse(bodyText);
} catch {
throw new KlingoApiError(
"AUTHENTICATION_ERROR",
"A resposta de identificação do paciente no Klingo não é um JSON válido.",
);
}
const token =
typeof parsed === "object" && parsed !== null
? (parsed as { access_token?: unknown }).access_token
: undefined;
if (typeof token !== "string" || token.trim() === "") {
throw new KlingoApiError(
"AUTHENTICATION_ERROR",
"A resposta de identificação do paciente no Klingo não trouxe um access_token válido.",
);
}
return token;
}
@@ -0,0 +1,115 @@
/**
* adapters/klingo/klingo.types.ts
*
* Tipos internos do KlingoAdapter, refletindo exclusivamente o que está
* documentado em `docs/klingo-openapi.json` (Swagger público da API do
* Klingo). Nenhum campo aqui foi inventado.
*/
/** Corpo de `POST /paciente/identificar`. */
export interface KlingoIdentificarPacienteBody {
telefone: string;
cpf?: string;
dt_nascimento?: string;
apenas_telefone?: boolean;
}
/** Corpo de `POST /externo/login`. */
export interface KlingoLoginBody {
/** Id do paciente, precedido de "P" (ex.: "P123"). */
id: string;
}
/**
* Resposta de `POST /paciente/identificar` e `POST /externo/login`
* (definição `sessao`).
*/
export interface KlingoSessaoResponse {
access_token: string;
token_type: string;
expires_in?: number;
user?: {
id?: number;
nome?: string;
id_paciente?: number;
};
unidade?: {
nome?: string;
};
}
/** Query params de `GET /agenda/horarios`. */
export interface KlingoHorariosQuery {
especialidade?: string;
exame?: string;
inicio?: string;
fim?: string;
crm?: string;
cnes?: string;
}
/** Um profissional (definição `profissional`, campos documentados usados aqui). */
export interface KlingoProfissional {
id?: number;
nome?: string;
crm?: string;
}
/** Uma unidade (definição `unidade`, campos documentados usados aqui). */
export interface KlingoUnidade {
nome?: string;
cnes?: string;
}
/**
* Um bloco de horários por data/procedimento (item de
* `horariosAgenda.horarios[]`).
*
* IMPORTANTE: o campo `horarios` (lista de horários com id e hora, por
* dentro deste bloco) está documentado no Swagger apenas como
* `{"type": "object", "description": "Lista de Horários com ID e horário."}`,
* sem `properties`/`items`. Não há como determinar os nomes reais dos
* campos internos (id do horário específico, hora) sem uma resposta real
* capturada da API — por isso ele é tipado como `unknown` aqui, e o
* adapter NÃO tenta interpretá-lo (ver `KlingoApiError` código
* `SLOT_MAPPING_UNAVAILABLE`).
*/
export interface KlingoHorarioBloco {
id?: string;
data?: string;
procedimento?: string;
ordem_chegada?: boolean;
faixa_ordem_chegada?: string;
/** Estrutura interna não documentada — nunca interpretado pelo adapter. */
horarios?: unknown;
profissional?: KlingoProfissional;
unidade?: KlingoUnidade;
}
/** Resposta de `GET /agenda/horarios` (definição `horariosAgenda`). */
export interface KlingoHorariosAgendaResponse {
procedimento?: string;
horarios?: KlingoHorarioBloco[];
}
/** Corpo de `POST /agenda/reservar` (definição `horarioMarcar`). */
export interface KlingoHorarioMarcarBody {
procedimento: string;
id: string;
plano?: number;
email?: boolean;
teleatendimento?: boolean;
revisao?: boolean;
confirmado?: string;
id_externo?: string;
obs?: string;
}
/** Resposta de `POST /agenda/reservar` (definição `reserva`). */
export interface KlingoReservaResponse {
id: number;
expira?: string;
data?: string;
hora?: string;
profissional?: KlingoProfissional;
}
@@ -0,0 +1,32 @@
/**
* adapters/scheduling-adapter.interface.ts
*
* Contrato que todo Adapter de agendamento deve implementar.
*
* O MCP nunca conhece detalhes de nenhum sistema de clínica específico.
* Toda a responsabilidade de integração (chamadas HTTP, autenticação,
* regras próprias de cada sistema, etc.) pertence exclusivamente ao Adapter
* que implementa esta interface.
*
* Novos sistemas (ex.: KlingoAdapter, SysLifeAdapter, ClinicAllAdapter)
* devem implementar exatamente esta mesma interface.
*/
import type {
AvailableSlot,
Appointment,
QueryAvailableSlotsInput,
CreateAppointmentInput,
} from "../types/scheduling.types.js";
export interface SchedulingAdapter {
/**
* Consulta os horários disponíveis para agendamento.
*/
consultarHorarios(input: QueryAvailableSlotsInput): Promise<AvailableSlot[]>;
/**
* Realiza um agendamento em um horário disponível.
*/
agendar(input: CreateAppointmentInput): Promise<Appointment>;
}
@@ -0,0 +1,148 @@
/**
* adapters/zpro/zpro-http-client.ts
*
* Cliente HTTP mínimo do ZProAdapter para
* `POST /v2/api/external/{ApiID}/appointment/create`.
*
* Usa o `fetch` nativo do Node.js (sem Axios). Aplica autenticação Bearer,
* timeout via AbortController, envia/recebe JSON e nunca inclui o token
* em mensagens de erro. Não faz retry.
*
* Segue o mesmo padrão já usado em `src/diagnostics/client.ts` (mantido
* separado porque um é infraestrutura de produção do adapter e o outro é
* um script de diagnóstico somente leitura).
*/
import type { ZProConfig } from "../../config/config.types.js";
import type { ZProAppointmentCreateBody } from "./zpro.types.js";
import { ZProApiError } from "./zpro.errors.js";
/** Timeout padrão da requisição de criação de agendamento (15 segundos). */
export const DEFAULT_TIMEOUT_MS = 15_000;
/** Resultado bruto de uma chamada HTTP ao Z-PRO. */
export interface ZProHttpResponse {
status: number;
contentType: string | null;
bodyText: string;
}
/**
* Metadados seguros de diagnóstico sobre uma resposta HTTP do Z-PRO.
*
* Contém apenas informações de transporte (status, content-type, formato
* aparente do corpo) — NUNCA o corpo em si, headers de autenticação, nem
* qualquer dado do paciente. Seguro para logar/registrar.
*/
export interface ZProResponseMetadata {
status: number;
contentType: string | null;
hasBody: boolean;
/** Formato aparente do corpo, sem interpretar nenhum campo dele. */
bodyFormat: "empty" | "json" | "non-json";
}
/**
* Deriva metadados seguros de diagnóstico a partir de uma resposta.
* Não lê nenhum campo do corpo — só verifica se ele é JSON sintaticamente
* válido, para classificar o formato aparente.
*/
export function describeResponseMetadata(
response: ZProHttpResponse,
): ZProResponseMetadata {
const trimmed = response.bodyText.trim();
if (trimmed.length === 0) {
return {
status: response.status,
contentType: response.contentType,
hasBody: false,
bodyFormat: "empty",
};
}
try {
JSON.parse(trimmed);
return {
status: response.status,
contentType: response.contentType,
hasBody: true,
bodyFormat: "json",
};
} catch {
return {
status: response.status,
contentType: response.contentType,
hasBody: true,
bodyFormat: "non-json",
};
}
}
/** Monta a URL de `appointment/create` a partir da configuração. */
export function buildAppointmentCreateUrl(
config: Pick<ZProConfig, "baseUrl" | "apiId">,
): string {
const baseUrl = config.baseUrl.replace(/\/+$/, "");
const apiId = encodeURIComponent(config.apiId);
return `${baseUrl}/v2/api/external/${apiId}/appointment/create`;
}
function isAbortError(error: unknown): boolean {
return error instanceof Error && error.name === "AbortError";
}
/**
* Executa a requisição `POST appointment/create`.
*
* `fetchImpl` permite injetar um fetch fake em testes; `timeoutMs`
* controla o tempo máximo da requisição. Erros de timeout e de rede são
* normalizados em `ZProApiError`, sem nunca incluir o token na mensagem.
*/
export async function postAppointmentCreate(
config: ZProConfig,
body: ZProAppointmentCreateBody,
options: {
timeoutMs?: number;
fetchImpl?: typeof fetch;
} = {},
): Promise<ZProHttpResponse> {
const { timeoutMs = DEFAULT_TIMEOUT_MS, fetchImpl = fetch } = options;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetchImpl(buildAppointmentCreateUrl(config), {
method: "POST",
headers: {
Authorization: `Bearer ${config.apiToken}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(body),
signal: controller.signal,
});
const bodyText = await response.text();
return {
status: response.status,
contentType: response.headers.get("content-type"),
bodyText,
};
} catch (error) {
if (isAbortError(error)) {
throw new ZProApiError(
"ZPRO_TIMEOUT",
"Tempo limite excedido ao criar o agendamento no Z-PRO.",
);
}
throw new ZProApiError(
"ZPRO_API_ERROR",
"Falha de rede ao criar o agendamento no Z-PRO.",
);
} finally {
clearTimeout(timer);
}
}
@@ -0,0 +1,133 @@
/**
* adapters/zpro/zpro.adapter.ts
*
* Adapter responsável pela integração com o sistema Z-Pro.
*
* `agendar()`: chama de fato `POST appointment/create`. A requisição
* (autenticação, URL, corpo) é 100% baseada em contrato verificado na
* coleção pública do Postman (ver `docs/zpro-api-mapping.md`).
*
* O mapeamento da resposta de SUCESSO foi verificado por um teste manual
* real em 09/08/2026 (ver `docs/zpro-api-mapping.md`, seção "Atualização
* — resposta real de sucesso capturada"): quando o corpo bate com o
* envelope observado (`{success: true, data: {id, startAt, endAt,
* status}}`), o `Appointment` é construído com o `id` real retornado pela
* API (`zpro.mapper.ts#buildAppointmentFromResponse`).
*
* Mesmo assim, QUALQUER HTTP 2xx continua sendo tratado como sucesso
* operacional — o adapter NÃO lança erro quando o corpo foge desse
* envelope (JSON com outro formato, texto simples, vazio, ou JSON
* malformado). Só temos um único exemplo real capturado; reportar como
* falha uma operação que o provider aceitou (e que pode já ter sido
* persistida) induziria um consumidor a repeti-la (risco de agendamento
* duplicado). Nesse caso, retorna um `Appointment` construído apenas com o
* que já era conhecido do próprio input, com `responseMappingPending:
* true`. Ver `zpro.mapper.ts#buildPendingMappingAppointment`.
*
* `consultarHorarios()`: decisão de produto — o Z-PRO não expõe nenhum
* endpoint de disponibilidade de horários. Não há solução alternativa
* implementada (não se tenta calcular horários livres a partir da lista
* de agendamentos existentes).
*/
import type { SchedulingAdapter } from "../scheduling-adapter.interface.js";
import type {
AvailableSlot,
Appointment,
QueryAvailableSlotsInput,
CreateAppointmentInput,
} from "../../types/scheduling.types.js";
import type { ZProConfig } from "../../config/config.types.js";
import { ZProApiError, classifyHttpErrorStatus } from "./zpro.errors.js";
import {
buildAppointmentCreateBody,
buildAppointmentFromResponse,
buildPendingMappingAppointment,
tryParseAppointmentCreateResponse,
} from "./zpro.mapper.js";
import {
describeResponseMetadata,
postAppointmentCreate,
type ZProResponseMetadata,
} from "./zpro-http-client.js";
export class ZProAdapter implements SchedulingAdapter {
private readonly config: ZProConfig;
private readonly fetchImpl?: typeof fetch;
private readonly timeoutMs?: number;
private readonly onResponseMetadata?: (
metadata: ZProResponseMetadata,
) => void;
/**
* `options.fetchImpl`/`options.timeoutMs` permitem injetar um fetch
* fake e um timeout curto em testes (mesmo padrão de
* `src/diagnostics/client.ts`), sem alterar o contrato usado pela
* AdapterFactory (`new ZProAdapter(config)` continua válido).
*
* `options.onResponseMetadata`, quando fornecido, é chamado com
* metadados seguros de diagnóstico (status, content-type, presença de
* corpo, formato aparente) após cada resposta do Z-PRO — nunca com o
* corpo em si, headers de autenticação, ou dados do paciente. Sem
* efeito por padrão (não loga nada sozinho, para nunca escrever em
* stdout e corromper o transporte stdio do MCP).
*/
constructor(
config: ZProConfig,
options: {
fetchImpl?: typeof fetch;
timeoutMs?: number;
onResponseMetadata?: (metadata: ZProResponseMetadata) => void;
} = {},
) {
this.config = config;
this.fetchImpl = options.fetchImpl;
this.timeoutMs = options.timeoutMs;
this.onResponseMetadata = options.onResponseMetadata;
}
/**
* O Z-PRO não possui endpoint de consulta de disponibilidade de
* horários (ver `docs/zpro-api-mapping.md`). Não implementar soluções
* alternativas (ex.: inferir disponibilidade a partir da lista de
* agendamentos existentes) — isso inventaria uma regra de negócio não
* documentada pela API.
*/
async consultarHorarios(
_input: QueryAvailableSlotsInput,
): Promise<AvailableSlot[]> {
throw new ZProApiError(
"NOT_SUPPORTED",
"O provider Z-PRO não oferece consulta de disponibilidade de horários.",
);
}
async agendar(input: CreateAppointmentInput): Promise<Appointment> {
const body = buildAppointmentCreateBody(input);
const response = await postAppointmentCreate(this.config, body, {
fetchImpl: this.fetchImpl,
timeoutMs: this.timeoutMs,
});
this.onResponseMetadata?.(describeResponseMetadata(response));
if (response.status < 200 || response.status >= 300) {
throw classifyHttpErrorStatus(response.status);
}
// QUALQUER HTTP 2xx é sucesso operacional — o provider aceitou a
// operação. Quando o corpo bate com o envelope real verificado
// (ver docs/zpro-api-mapping.md), mapeamos os dados de verdade
// (id real, etc.). Caso contrário — corpo vazio, texto simples, JSON
// malformado ou formato diferente do único exemplo capturado — não
// tratamos como erro (só temos um exemplo real; não arriscamos
// reportar falha numa operação já aceita) e retornamos um Appointment
// "aceito, mapeamento pendente" construído somente a partir do input.
const data = tryParseAppointmentCreateResponse(response.bodyText);
if (data) {
return buildAppointmentFromResponse(data, input);
}
return buildPendingMappingAppointment(input);
}
}
@@ -0,0 +1,72 @@
/**
* adapters/zpro/zpro.errors.ts
*
* Erros internos, previsíveis e tipados do ZProAdapter.
*
* Nenhuma mensagem de erro expõe token, headers sensíveis, stack trace ou
* corpo bruto da resposta da API. Os códigos abaixo cobrem exatamente os
* cenários verificados/possíveis com a integração real do endpoint
* `appointment/create` do Z-PRO:
*
* - VALIDATION_ERROR -> HTTP 400 / 422
* - AUTHENTICATION_ERROR -> HTTP 401 / 403
* - NOT_FOUND -> HTTP 404
* - ZPRO_API_ERROR -> demais erros HTTP (ex.: 500) e falha de rede
* - ZPRO_TIMEOUT -> requisição excedeu o tempo limite
* - NOT_SUPPORTED -> operação que este provider não oferece
* (decisão de produto, não falha técnica)
*
* IMPORTANTE: QUALQUER HTTP 2xx é tratado como sucesso operacional — não
* existe (e não deve existir) um código de erro para "resposta de sucesso
* que não pôde ser interpretada". Corpo vazio, texto simples ou JSON
* malformado em uma resposta 2xx NÃO são erros: o provider aceitou a
* operação, e reportar isso como falha induziria um consumidor a
* repeti-la (risco de agendamento duplicado). O adapter retorna
* normalmente um `Appointment` com `responseMappingPending: true` (ver
* `zpro.mapper.ts` e `Appointment.responseMappingPending` em
* `scheduling.types.ts`).
*/
export type ZProErrorCode =
| "VALIDATION_ERROR"
| "AUTHENTICATION_ERROR"
| "NOT_FOUND"
| "ZPRO_API_ERROR"
| "ZPRO_TIMEOUT"
| "NOT_SUPPORTED";
/** Erro tipado do ZProAdapter, com um código previsível e mensagem curta. */
export class ZProApiError extends Error {
readonly code: ZProErrorCode;
constructor(code: ZProErrorCode, message: string) {
super(message);
this.name = "ZProApiError";
this.code = code;
}
}
/** Classifica um status HTTP de erro (fora do intervalo 2xx) em um ZProApiError. */
export function classifyHttpErrorStatus(status: number): ZProApiError {
if (status === 401 || status === 403) {
return new ZProApiError(
"AUTHENTICATION_ERROR",
`Falha de autenticação na API do Z-PRO (status ${status}).`,
);
}
if (status === 404) {
return new ZProApiError(
"NOT_FOUND",
`Recurso não encontrado na API do Z-PRO (status ${status}). Verifique ZPRO_API_ID.`,
);
}
if (status === 400 || status === 422) {
return new ZProApiError(
"VALIDATION_ERROR",
`A API do Z-PRO rejeitou os dados enviados (status ${status}).`,
);
}
return new ZProApiError(
"ZPRO_API_ERROR",
`A API do Z-PRO retornou um erro inesperado (status ${status}).`,
);
}
@@ -0,0 +1,175 @@
/**
* adapters/zpro/zpro.mapper.ts
*
* Mapeamento entre os tipos genéricos do projeto e o contrato específico
* da API Z-PRO. Isolado do cliente HTTP (zpro-http-client.ts) e do
* adapter (zpro.adapter.ts).
*
* Contém o mapeamento de REQUEST (genérico -> Z-PRO) e dois caminhos de
* mapeamento de RESPOSTA de sucesso:
*
* 1. `tryParseAppointmentCreateResponse` + `buildAppointmentFromResponse`
* — quando o corpo bate com o envelope real verificado
* (`{success: true, data: {id, startAt, endAt, status}}`, ver
* `docs/zpro-api-mapping.md`), o `Appointment` é montado com os
* dados REAIS da resposta (id numérico real, etc.).
* 2. `buildPendingMappingAppointment` — quando o corpo não bate com esse
* envelope (formato mudou, campo ausente, não é JSON, etc.), constrói
* um `Appointment` "aceito, mapeamento pendente" usando apenas o
* INPUT. Nunca inventa um `id` a partir de um corpo que não segue o
* formato verificado — essa é a proteção contra duplicidade mantida
* mesmo após a resposta real ter sido capturada.
*/
import type {
Appointment,
AppointmentStatus,
CreateAppointmentInput,
} from "../../types/scheduling.types.js";
import type {
ZProAppointmentCreateBody,
ZProAppointmentCreateResponse,
} from "./zpro.types.js";
/**
* Monta o `title` enviado ao Z-PRO: usa o valor informado pelo chamador
* quando existir; caso contrário, gera um valor padrão a partir do nome
* do paciente. Essa lógica é específica do Z-PRO (que exige um título) e
* fica isolada aqui, sem contaminar o tipo genérico com uma convenção de
* um provider só.
*/
function buildTitle(input: CreateAppointmentInput): string {
return input.title?.trim()
? input.title.trim()
: `Consulta - ${input.patient.name}`;
}
/**
* Converte o input genérico de criação de agendamento no corpo esperado
* por `POST /v2/api/external/{ApiID}/appointment/create`.
*
* Só inclui campos do Z-PRO para os quais existe uma fonte de dados no
* tipo genérico (`contactId` a partir de `patient.id`, `contactPhone` a
* partir de `patient.phone`). Campos do Z-PRO sem fonte no domínio
* genérico (`description`, `whatsappId`, `notes`) não são enviados.
*/
export function buildAppointmentCreateBody(
input: CreateAppointmentInput,
): ZProAppointmentCreateBody {
const body: ZProAppointmentCreateBody = {
title: buildTitle(input),
contactName: input.patient.name,
startAt: input.startAt,
endAt: input.endAt,
status: "pending",
};
if (input.patient.id) {
body.contactId = input.patient.id;
}
if (input.patient.phone) {
body.contactPhone = input.patient.phone;
}
return body;
}
/**
* Constrói o `Appointment` retornado quando a API do Z-PRO confirma
* sucesso (HTTP 2xx) mas o schema de resposta ainda não foi verificado.
*
* Usa EXCLUSIVAMENTE dados já conhecidos e confiáveis do próprio `input`
* (o que foi enviado e aceito) — nunca lê `parsed`/o corpo da resposta.
* Por isso `id` e `slot.id` ficam ausentes (não inventados) e
* `responseMappingPending` é sinalizado como `true`, deixando explícito
* que o provider aceitou a operação e que ela não deve ser repetida.
*/
export function buildPendingMappingAppointment(
input: CreateAppointmentInput,
): Appointment {
return {
slot: {
startDateTime: input.startAt,
endDateTime: input.endAt,
},
patient: input.patient,
status: "pending",
responseMappingPending: true,
};
}
/**
* Mapeia o `status` textual retornado pelo Z-PRO para o tipo genérico
* `AppointmentStatus`. Apenas `"pending"` foi observado em uma resposta
* real (é o único valor que este adapter envia hoje); `"confirmed"` e
* `"cancelled"` são mapeados por analogia com os valores documentados no
* filtro de `AppointmentList` (`pending | confirmed | cancelled |
* completed`), não por observação direta. Qualquer outro valor (incluindo
* `"completed"`, que não tem correspondente genérico) cai em `"pending"`,
* a opção mais conservadora — nunca reporta `"confirmed"` sem uma
* confirmação textual explícita da API.
*/
function mapZProStatus(status: string): AppointmentStatus {
if (status === "confirmed") return "confirmed";
if (status === "cancelled") return "cancelled";
return "pending";
}
/**
* Tenta interpretar o corpo de `appointment/create` como o envelope de
* sucesso REAL verificado (`{success: true, data: {id, startAt, endAt,
* status}}`). Retorna `undefined` (nunca lança) quando o corpo não é JSON
* válido ou não bate com esse formato — nesses casos o chamador deve usar
* `buildPendingMappingAppointment`, nunca inventar os campos faltantes.
*/
export function tryParseAppointmentCreateResponse(
bodyText: string,
): ZProAppointmentCreateResponse["data"] | undefined {
let parsed: unknown;
try {
parsed = JSON.parse(bodyText);
} catch {
return undefined;
}
if (typeof parsed !== "object" || parsed === null) return undefined;
const envelope = parsed as Partial<ZProAppointmentCreateResponse>;
if (envelope.success !== true) return undefined;
const data = envelope.data;
if (
typeof data !== "object" ||
data === null ||
typeof data.id !== "number" ||
typeof data.startAt !== "string" ||
typeof data.endAt !== "string" ||
typeof data.status !== "string"
) {
return undefined;
}
return data;
}
/**
* Constrói o `Appointment` a partir do envelope de sucesso REAL da
* resposta (já validado por `tryParseAppointmentCreateResponse`). Usa o
* `id`, `startAt`, `endAt` e `status` retornados pela API — dados
* observados de verdade, não inventados. `patient` continua vindo do
* `input`: a resposta ecoa `contactName`/`contactPhone`, mas não traz
* nenhum dado de paciente que o `input` já não tivesse.
*/
export function buildAppointmentFromResponse(
data: NonNullable<ZProAppointmentCreateResponse["data"]>,
input: CreateAppointmentInput,
): Appointment {
return {
id: String(data.id),
slot: {
startDateTime: data.startAt,
endDateTime: data.endAt,
},
patient: input.patient,
status: mapZProStatus(data.status),
};
}
@@ -0,0 +1,56 @@
/**
* adapters/zpro/zpro.types.ts
*
* Tipos internos do Z-PRO. Podem usar nomes específicos da API (diferente
* de `src/types/scheduling.types.ts`, que é genérico).
*
* Contém o corpo da requisição de `POST appointment/create` (verificado no
* exemplo salvo na coleção pública do Postman) e o envelope de SUCESSO da
* resposta, verificado em 09/08/2026 por um teste manual único e
* controlado contra uma instância real do Z-PRO (ver
* `docs/zpro-api-mapping.md`, seção "Atualização — resposta real de
* sucesso capturada").
*/
/**
* Corpo da requisição de `POST /v2/api/external/{ApiID}/appointment/create`.
*
* Campos verificados no exemplo salvo na coleção: `title`, `description`,
* `contactId`, `contactName`, `contactPhone`, `whatsappId`, `startAt`,
* `endAt`, `status`, `notes`. A coleção não declara quais são obrigatórios;
* este adapter só envia os campos para os quais existe uma fonte de dados
* confiável no domínio genérico (ver `zpro.mapper.ts`).
*/
export interface ZProAppointmentCreateBody {
title: string;
contactId?: string;
contactName: string;
contactPhone?: string;
startAt: string;
endAt: string;
status: "pending";
}
/**
* Envelope de SUCESSO de `POST appointment/create`, verificado por um
* teste manual real (um único exemplo capturado). Só os campos realmente
* observados na resposta estão tipados como obrigatórios; os demais
* campos existem na resposta real mas não são utilizados pelo adapter
* (`description`, `whatsappId`, `tenantId`, `notes`, etc. — todos `null`
* no exemplo capturado).
*
* IMPORTANTE: só o valor `status: "pending"` foi observado na prática (é o
* que este adapter sempre envia). Os valores `"confirmed"`/`"cancelled"`
* não foram confirmados por uma resposta real — o mapeamento para esses
* casos (`zpro.mapper.ts`) é uma extrapolação razoável, não uma observação
* direta.
*/
export interface ZProAppointmentCreateResponse {
success: boolean;
data?: {
id: number;
startAt: string;
endAt: string;
status: string;
};
}
+1
View File
@@ -0,0 +1 @@
Pasta reservada para configurações futuras (variáveis de ambiente, constantes de configuração, etc.). Vazia propositalmente neste esqueleto inicial.
@@ -0,0 +1,59 @@
/**
* config/config.types.ts
*
* Tipos de configuração do agendamento.
*
* A configuração genérica (SchedulingProviderConfig) é uma união
* discriminada pela propriedade `provider`, permitindo que futuramente
* novos sistemas sejam adicionados sem alterar o contrato dos Adapters.
*
* Campos presentes aqui refletem apenas o que já foi verificado/definido
* para cada provider. Não devem existir campos imaginários para sistemas
* ainda não integrados (SysLife, ClinicAll, etc.).
*/
/**
* Configuração específica do sistema Z-PRO.
*/
export interface ZProConfig {
/** Nome do provider, usado para selecionar o Adapter correto. */
provider: "zpro";
/** URL base da API do Z-PRO. */
baseUrl: string;
/** Token de autenticação da API do Z-PRO. */
apiToken: string;
/** Identificador da API (ApiID) usado nas rotas externas do Z-PRO. */
apiId: string;
/**
* Identificador da instância no Z-PRO.
* Mantido temporariamente por compatibilidade; não é utilizado pelo
* diagnóstico.
*/
instanceId?: string;
}
/**
* Configuração específica do sistema Klingo.
*
* Reflete apenas o que está documentado no Swagger público da API
* (`docs/klingo-openapi.json`): autenticação por `X-APP-TOKEN` (token de
* aplicação, estático). O token de sessão do paciente (`Authorization:
* Bearer`, obtido via `/externo/login` ou `/paciente/identificar`) NÃO é
* uma variável de ambiente — ele é obtido em tempo de execução, por
* paciente, e não deve ser um segredo fixo de configuração.
*/
export interface KlingoConfig {
/** Nome do provider, usado para selecionar o Adapter correto. */
provider: "klingo";
/** URL base da API do Klingo. */
baseUrl: string;
/** Token de aplicação do Klingo, enviado no header `X-APP-TOKEN`. */
appToken: string;
}
/**
* Configuração genérica do provider de agendamento.
*
* Novos providers devem adicionar seu próprio tipo à união.
*/
export type SchedulingProviderConfig = ZProConfig | KlingoConfig;
+122
View File
@@ -0,0 +1,122 @@
/**
* config/env.config.ts
*
* Módulo responsável por ler e validar a configuração da aplicação por
* variáveis de ambiente.
*
* A leitura de `process.env` acontece EXCLUSIVAMENTE neste módulo.
*
* Variáveis lidas:
* - SCHEDULING_PROVIDER (padrão: "zpro"; valores: "zpro" | "klingo")
*
* Quando SCHEDULING_PROVIDER=zpro:
* - ZPRO_BASE_URL (obrigatória)
* - ZPRO_API_TOKEN (obrigatória)
* - ZPRO_API_ID (obrigatória)
* - ZPRO_INSTANCE_ID (opcional, mantida por compatibilidade)
*
* Quando SCHEDULING_PROVIDER=klingo:
* - KLINGO_BASE_URL (obrigatória)
* - KLINGO_APP_TOKEN (obrigatória) — token de aplicação (X-APP-TOKEN)
*
* As variáveis de um provider não selecionado NÃO são exigidas (ex.: não
* é preciso definir ZPRO_* quando SCHEDULING_PROVIDER=klingo).
*
* A configuração NUNCA deve expor nenhum token em mensagens de erro ou logs.
*/
import { z } from "zod";
import type { SchedulingProviderConfig } from "./config.types.js";
/** Converte valores vazios em `undefined`, permitindo tratar variáveis
* opcionais deixadas em branco como ausentes. */
function emptyToUndefined(value: unknown): unknown {
return typeof value === "string" && value.trim() === "" ? undefined : value;
}
/** Schema para uma variável textual opcional (validação condicional de
* obrigatoriedade acontece em `superRefine`, conforme o provider). */
const optionalString = z.preprocess(
emptyToUndefined,
z.string().trim().optional(),
);
const rawEnvSchema = z.object({
SCHEDULING_PROVIDER: z.preprocess(
emptyToUndefined,
z
.enum(["zpro", "klingo"], {
errorMap: () => ({
message: 'SCHEDULING_PROVIDER deve ser "zpro" ou "klingo".',
}),
})
.default("zpro"),
),
ZPRO_BASE_URL: optionalString,
ZPRO_API_TOKEN: optionalString,
ZPRO_API_ID: optionalString,
ZPRO_INSTANCE_ID: optionalString,
KLINGO_BASE_URL: optionalString,
KLINGO_APP_TOKEN: optionalString,
});
/** Adiciona um issue de "campo obrigatório" na variável informada. */
function requireField(
ctx: z.RefinementCtx,
fieldName: keyof z.infer<typeof rawEnvSchema>,
value: string | undefined,
): void {
if (value === undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `${fieldName} é obrigatória.`,
path: [fieldName],
});
}
}
const envSchema = rawEnvSchema
.superRefine((env, ctx) => {
if (env.SCHEDULING_PROVIDER === "zpro") {
requireField(ctx, "ZPRO_BASE_URL", env.ZPRO_BASE_URL);
requireField(ctx, "ZPRO_API_TOKEN", env.ZPRO_API_TOKEN);
requireField(ctx, "ZPRO_API_ID", env.ZPRO_API_ID);
} else if (env.SCHEDULING_PROVIDER === "klingo") {
requireField(ctx, "KLINGO_BASE_URL", env.KLINGO_BASE_URL);
requireField(ctx, "KLINGO_APP_TOKEN", env.KLINGO_APP_TOKEN);
}
})
.transform((env): SchedulingProviderConfig => {
if (env.SCHEDULING_PROVIDER === "klingo") {
return {
provider: "klingo",
baseUrl: env.KLINGO_BASE_URL as string,
appToken: env.KLINGO_APP_TOKEN as string,
};
}
return {
provider: "zpro",
baseUrl: env.ZPRO_BASE_URL as string,
apiToken: env.ZPRO_API_TOKEN as string,
apiId: env.ZPRO_API_ID as string,
instanceId: env.ZPRO_INSTANCE_ID,
};
});
/**
* Lê e valida a configuração a partir das variáveis de ambiente.
*
* Lança um `Error` com mensagem clara (e sem segredos) quando uma
* variável obrigatória estiver ausente ou inválida.
*/
export function loadSchedulingConfig(
env: NodeJS.ProcessEnv = process.env,
): SchedulingProviderConfig {
const result = envSchema.safeParse(env);
if (!result.success) {
const issues = result.error.issues.map((issue) => issue.message).join(" ");
throw new Error(`Falha ao carregar a configuração: ${issues}`);
}
return result.data;
}
@@ -0,0 +1,86 @@
/**
* diagnostics/analyze.ts
*
* Identificação da estrutura de uma resposta JSON, sem inventar
* interpretações: apenas relata o que realmente existe na resposta
* (tipo da raiz, chaves, tipos dos campos e possíveis campos de
* paginação/identificação/data/status, com base em heurísticas simples
* de nomeação).
*/
/** Resumo estrutural de uma resposta JSON. */
export interface StructureSummary {
/** Tipo da raiz (object, array, string, number, boolean, null). */
rootType: string;
/** Chaves existentes na raiz, quando ela é um objeto. */
rootKeys: string[];
/** Quantidade de itens, quando a raiz é um array. */
arrayItemCount?: number;
/** Chaves do primeiro item (ou da raiz, quando ela é um objeto). */
firstItemKeys: string[];
/** Tipos dos campos do primeiro item (ou da raiz, quando objeto). */
fieldTypes: Record<string, string>;
/** Possíveis campos de paginação encontrados. */
possiblePaginationFields: string[];
/** Possíveis campos de identificação encontrados. */
possibleIdFields: string[];
/** Possíveis campos de data encontrados. */
possibleDateFields: string[];
/** Possíveis campos de status encontrados. */
possibleStatusFields: string[];
}
const PAGINATION_PATTERN = /page|per.?page|limit|offset|total|count/i;
const ID_PATTERN = /(^|_)(id|uuid)$|(^|_)(identifier|identificador)$|Id$/;
const DATE_PATTERN = /date|time|at$|timestamp|created|updated|scheduled/i;
const STATUS_PATTERN = /status|state|situa/i;
function typeOf(value: unknown): string {
if (Array.isArray(value)) return "array";
if (value === null) return "null";
return typeof value;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/**
* Analisa a estrutura de um valor JSON já interpretado.
*/
export function analyzeStructure(value: unknown): StructureSummary {
const rootType = Array.isArray(value) ? "array" : typeOf(value);
const rootKeys = isRecord(value) ? Object.keys(value) : [];
let sample: unknown = value;
let arrayItemCount: number | undefined;
if (Array.isArray(value)) {
arrayItemCount = value.length;
sample = value[0];
}
const firstItemKeys = isRecord(sample) ? Object.keys(sample) : [];
const fieldTypes: Record<string, string> = {};
if (isRecord(sample)) {
for (const [key, entry] of Object.entries(sample)) {
fieldTypes[key] = typeOf(entry);
}
}
return {
rootType,
rootKeys,
arrayItemCount,
firstItemKeys,
fieldTypes,
possiblePaginationFields: firstItemKeys.filter((key) =>
PAGINATION_PATTERN.test(key),
),
possibleIdFields: firstItemKeys.filter((key) => ID_PATTERN.test(key)),
possibleDateFields: firstItemKeys.filter((key) => DATE_PATTERN.test(key)),
possibleStatusFields: firstItemKeys.filter((key) =>
STATUS_PATTERN.test(key),
),
};
}
@@ -0,0 +1,101 @@
/**
* diagnostics/client.ts
*
* Cliente HTTP mínimo para o script de diagnóstico do Z-PRO.
*
* Faz apenas uma requisição GET (leitura) em
* `/v2/api/external/{ApiID}/appointment/list`, sem retry, com timeout
* configurável. Nenhuma mensagem de erro expõe o token.
*/
import type { ZProConfig } from "../config/config.types.js";
/** Timeout padrão da requisição de diagnóstico (15 segundos). */
export const DEFAULT_TIMEOUT_MS = 15_000;
/** Resultado bruto da requisição. */
export interface ZProListResponse {
status: number;
contentType: string | null;
bodyText: string;
durationMs: number;
}
/** Erro lançado quando a requisição excede o timeout. */
export class RequestTimeoutError extends Error {
constructor() {
super("Timeout ao consultar a API do Z-PRO.");
this.name = "RequestTimeoutError";
}
}
/** Erro lançado em falhas de rede (sem detalhes que exponham segredos). */
export class NetworkRequestError extends Error {
constructor() {
super("Falha de rede ao consultar a API do Z-PRO.");
this.name = "NetworkRequestError";
}
}
/**
* Monta a URL do endpoint de listagem com os parâmetros mais
* conservadores: page=1 e limit=5.
*/
export function buildAppointmentListUrl(
config: Pick<ZProConfig, "baseUrl" | "apiId">,
): string {
const baseUrl = config.baseUrl.replace(/\/+$/, "");
const apiId = encodeURIComponent(config.apiId);
return `${baseUrl}/v2/api/external/${apiId}/appointment/list?page=1&limit=5`;
}
function isAbortError(error: unknown): boolean {
return error instanceof Error && error.name === "AbortError";
}
/**
* Executa a requisição GET de listagem de agendamentos.
*
* `fetchImpl` permite injetar um fetch fake em testes; `timeoutMs`
* controla o tempo máximo da requisição.
*/
export async function fetchAppointmentList(
config: ZProConfig,
options: {
timeoutMs?: number;
fetchImpl?: typeof fetch;
} = {},
): Promise<ZProListResponse> {
const { timeoutMs = DEFAULT_TIMEOUT_MS, fetchImpl = fetch } = options;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const startedAt = Date.now();
try {
const response = await fetchImpl(buildAppointmentListUrl(config), {
method: "GET",
headers: {
Authorization: `Bearer ${config.apiToken}`,
Accept: "application/json",
},
signal: controller.signal,
});
const bodyText = await response.text();
return {
status: response.status,
contentType: response.headers.get("content-type"),
bodyText,
durationMs: Date.now() - startedAt,
};
} catch (error) {
if (isAbortError(error)) {
throw new RequestTimeoutError();
}
throw new NetworkRequestError();
} finally {
clearTimeout(timer);
}
}
@@ -0,0 +1,56 @@
/**
* diagnostics/response.ts
*
* Tratamento da resposta recebida pelo script de diagnóstico: decide se
* o corpo é JSON e, em caso afirmativo, produz uma versão sanitizada e o
* resumo estrutural. Respostas não JSON são tratadas como texto (limitado
* e sanitizado).
*/
import { analyzeStructure, type StructureSummary } from "./analyze.js";
import { sanitizeRecursively, sanitizeText } from "./sanitize.js";
/** Relatório gerado a partir do corpo da resposta. */
export interface ResponseReport {
/** Se o corpo foi interpretado como JSON válido. */
isJson: boolean;
/** Versão sanitizada (e limitada, no caso de texto) do corpo. */
sanitizedText: string;
/** Resumo estrutural, presente apenas quando a resposta é JSON. */
structure?: StructureSummary;
}
/** Tenta interpretar o texto como JSON; retorna null se não for JSON. */
export function tryParseJson(text: string): unknown | null {
try {
return JSON.parse(text);
} catch {
return null;
}
}
/**
* Constrói o relatório a partir do corpo bruto da resposta, sanitizando
* campos sensíveis e redigindo os segredos informados (ex.: o token).
*/
export function buildResponseReport(
bodyText: string,
secrets: string[] = [],
): ResponseReport {
const parsed = tryParseJson(bodyText);
if (parsed === null) {
return {
isJson: false,
sanitizedText: sanitizeText(bodyText, secrets),
};
}
const sanitized = sanitizeRecursively(parsed, secrets);
return {
isJson: true,
sanitizedText: JSON.stringify(sanitized, null, 2),
structure: analyzeStructure(parsed),
};
}
@@ -0,0 +1,105 @@
/**
* diagnostics/sanitize.ts
*
* Sanitização recursiva de respostas para o script de diagnóstico.
*
* Nenhuma informação sensível (nomes, telefones, e-mails, documentos,
* tokens, senhas, endereços) deve ser impressa. A sanitização mascara
* valores de campos cujos nomes contenham termos sensíveis e também
* redige ocorrências de segredos conhecidos (ex.: o token da API) dentro
* de qualquer texto.
*/
/** Valor que substitui conteúdo sensível na saída. */
export const REDACTED = "[REDACTED]";
/** Substrings de nomes de campo consideradas sensíveis. */
export const SENSITIVE_FIELD_NAMES = [
"name",
"nome",
"phone",
"telefone",
"email",
"cpf",
"document",
"documento",
"token",
"authorization",
"password",
"senha",
"address",
"endereco",
] as const;
/** Profundidade máxima de recursão, como proteção extra. */
const MAX_DEPTH = 20;
/** Verifica se um nome de campo é sensível (ignorando maiúsculas/minúsculas). */
export function isSensitiveField(key: string): boolean {
const lower = key.toLowerCase();
return SENSITIVE_FIELD_NAMES.some((term) => lower.includes(term));
}
/** Substitui todas as ocorrências de cada segredo pelo valor REDACTED. */
function redact(text: string, secrets: string[]): string {
let result = text;
for (const secret of secrets) {
if (!secret) continue;
result = result.split(secret).join(REDACTED);
}
return result;
}
/**
* Produz uma cópia sanitizada de um valor JSON (objeto, array ou primitivo).
*
* `secrets` são valores adicionais a redigir (ex.: o token da API), para
* que não apareçam nem mesmo em campos não sensíveis.
*/
export function sanitizeRecursively(
value: unknown,
secrets: string[] = [],
seen: WeakSet<object> = new WeakSet(),
depth = 0,
): unknown {
if (value === null || typeof value !== "object") {
if (typeof value === "string") {
return redact(value, secrets);
}
return value;
}
if (depth > MAX_DEPTH || seen.has(value)) {
return REDACTED;
}
seen.add(value);
if (Array.isArray(value)) {
const result = value.map((item) =>
sanitizeRecursively(item, secrets, seen, depth + 1),
);
seen.delete(value);
return result;
}
const result: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value)) {
result[key] = isSensitiveField(key)
? REDACTED
: sanitizeRecursively(entry, secrets, seen, depth + 1);
}
seen.delete(value);
return result;
}
/**
* Sanitiza um texto não JSON: redige segredos e limita o comprimento
* a `maxLength` caracteres (padrão: 500).
*/
export function sanitizeText(
text: string,
secrets: string[] = [],
maxLength = 500,
): string {
return redact(text, secrets).slice(0, maxLength);
}
+37
View File
@@ -0,0 +1,37 @@
/**
* index.ts
*
* Ponto de entrada da aplicação. Responsável por carregar e validar a
* configuração, criar o SchedulingAdapter via AdapterFactory e iniciar
* o servidor MCP.
*
* Com configuração inválida, a aplicação encerra com mensagem clara e
* sem revelar segredos.
*/
import { loadSchedulingConfig } from "./config/env.config.js";
import { createSchedulingAdapter } from "./adapters/adapter-factory.js";
import type { SchedulingAdapter } from "./adapters/scheduling-adapter.interface.js";
import { startMcpServer } from "./server/mcp.server.js";
function bootstrap(): void {
let adapter: SchedulingAdapter;
try {
const config = loadSchedulingConfig();
adapter = createSchedulingAdapter(config);
} catch (error) {
const message =
error instanceof Error
? error.message
: "Falha ao carregar a configuração.";
console.error(message);
process.exit(1);
}
startMcpServer(adapter).catch((error) => {
console.error("Falha ao iniciar o servidor MCP:", error);
process.exit(1);
});
}
bootstrap();
@@ -0,0 +1,65 @@
/**
* server/mcp.server.ts
*
* Configuração do servidor MCP.
*
* Responsável por instanciar o servidor e registrar as ferramentas
* disponíveis, que recebem o SchedulingAdapter por injeção de
* dependência. Nenhuma regra de negócio é implementada aqui.
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CONSULTAR_HORARIOS_TOOL_NAME,
CONSULTAR_HORARIOS_TOOL_DESCRIPTION,
consultarHorariosInputSchema,
createConsultarHorariosHandler,
} from "../tools/consultar-horarios.tool.js";
import {
AGENDAR_TOOL_NAME,
AGENDAR_TOOL_DESCRIPTION,
agendarInputSchema,
createAgendarHandler,
} from "../tools/agendar.tool.js";
import type { SchedulingAdapter } from "../adapters/scheduling-adapter.interface.js";
/**
* Cria e configura a instância do servidor MCP, registrando
* exclusivamente as ferramentas de agendamento suportadas por este MVP.
*
* O Adapter é injetado nas ferramentas e nunca é criado aqui.
*/
export function createMcpServer(adapter: SchedulingAdapter): McpServer {
const server = new McpServer({
name: "gti-scheduling-mcp",
version: "0.1.0",
});
server.tool(
CONSULTAR_HORARIOS_TOOL_NAME,
CONSULTAR_HORARIOS_TOOL_DESCRIPTION,
consultarHorariosInputSchema,
createConsultarHorariosHandler(adapter),
);
server.tool(
AGENDAR_TOOL_NAME,
AGENDAR_TOOL_DESCRIPTION,
agendarInputSchema,
createAgendarHandler(adapter),
);
return server;
}
/**
* Inicializa o servidor MCP utilizando o transporte stdio.
*/
export async function startMcpServer(
adapter: SchedulingAdapter,
): Promise<void> {
const server = createMcpServer(adapter);
const transport = new StdioServerTransport();
await server.connect(transport);
}
@@ -0,0 +1,85 @@
/**
* tools/agendar.tool.ts
*
* Definição da ferramenta MCP "agendar".
*
* Esta ferramenta expõe, via MCP, a criação de um agendamento.
* Nenhuma regra de negócio é implementada aqui: o handler apenas define
* o contrato (nome, descrição e schema de entrada) e delega a execução
* ao Adapter, recebido por injeção de dependência.
*/
import { z } from "zod";
import type { SchedulingAdapter } from "../adapters/scheduling-adapter.interface.js";
import type { CreateAppointmentInput } from "../types/scheduling.types.js";
import { toTextResponse, toErrorResponse } from "./mcp-response.js";
/** Nome da ferramenta, utilizado no registro do servidor MCP. */
export const AGENDAR_TOOL_NAME = "agendar";
/** Descrição da ferramenta, exibida ao cliente MCP. */
export const AGENDAR_TOOL_DESCRIPTION =
"Realiza um agendamento em um horário disponível de uma clínica.";
/** Schema de entrada da ferramenta. */
export const agendarInputSchema = {
clinicId: z.string().describe("Identificador da clínica"),
slotId: z
.string()
.optional()
.describe(
"Identificador do horário pré-selecionado (apenas para providers que suportam consulta de disponibilidade)",
),
specialtyId: z
.string()
.optional()
.describe(
"Identificador da especialidade/procedimento do horário pré-selecionado (copiado do resultado de consultar_horarios, quando o provider exigir)",
),
startAt: z
.string()
.describe("Data e hora de início do agendamento (ISO 8601)"),
endAt: z
.string()
.describe("Data e hora de término do agendamento (ISO 8601)"),
title: z
.string()
.optional()
.describe(
"Título/assunto do agendamento (usado apenas por providers que exigem um resumo textual)",
),
patient: z
.object({
id: z
.string()
.optional()
.describe("Identificador do paciente, se existente"),
name: z.string().describe("Nome completo do paciente"),
document: z
.string()
.optional()
.describe("Documento de identificação do paciente"),
phone: z.string().optional().describe("Telefone de contato do paciente"),
email: z.string().optional().describe("E-mail de contato do paciente"),
})
.describe("Dados do paciente a ser agendado"),
};
/**
* Cria o handler da ferramenta "agendar", delegando o agendamento ao
* Adapter recebido por injeção de dependência.
*
* Erros lançados pelo Adapter são capturados e convertidos em uma
* resposta MCP estruturada, sem encerrar o servidor.
*/
export function createAgendarHandler(adapter: SchedulingAdapter) {
return async function agendarHandler(args: CreateAppointmentInput) {
try {
const appointment = await adapter.agendar(args);
return toTextResponse(appointment);
} catch (error) {
return toErrorResponse(error);
}
};
}
@@ -0,0 +1,64 @@
/**
* tools/consultar-horarios.tool.ts
*
* Definição da ferramenta MCP "consultar_horarios".
*
* Esta ferramenta expõe, via MCP, a consulta de horários disponíveis.
* Nenhuma regra de negócio é implementada aqui: o handler apenas define
* o contrato (nome, descrição e schema de entrada) e delega a execução
* ao Adapter, recebido por injeção de dependência.
*/
import { z } from "zod";
import type { SchedulingAdapter } from "../adapters/scheduling-adapter.interface.js";
import type { QueryAvailableSlotsInput } from "../types/scheduling.types.js";
import { toTextResponse, toErrorResponse } from "./mcp-response.js";
/** Nome da ferramenta, utilizado no registro do servidor MCP. */
export const CONSULTAR_HORARIOS_TOOL_NAME = "consultar_horarios";
/** Descrição da ferramenta, exibida ao cliente MCP. */
export const CONSULTAR_HORARIOS_TOOL_DESCRIPTION =
"Consulta horários disponíveis para agendamento em uma clínica.";
/** Schema de entrada da ferramenta. */
export const consultarHorariosInputSchema = {
clinicId: z.string().describe("Identificador da clínica"),
professionalId: z
.string()
.optional()
.describe("Identificador do profissional (opcional)"),
specialtyId: z
.string()
.optional()
.describe("Identificador da especialidade/serviço (opcional)"),
startDate: z
.string()
.optional()
.describe("Data inicial do período de busca (ISO 8601)"),
endDate: z
.string()
.optional()
.describe("Data final do período de busca (ISO 8601)"),
};
/**
* Cria o handler da ferramenta "consultar_horarios", delegando a
* consulta ao Adapter recebido por injeção de dependência.
*
* Erros lançados pelo Adapter são capturados e convertidos em uma
* resposta MCP estruturada, sem encerrar o servidor.
*/
export function createConsultarHorariosHandler(adapter: SchedulingAdapter) {
return async function consultarHorariosHandler(
args: QueryAvailableSlotsInput,
) {
try {
const slots = await adapter.consultarHorarios(args);
return toTextResponse(slots);
} catch (error) {
return toErrorResponse(error);
}
};
}
@@ -0,0 +1,69 @@
/**
* tools/mcp-response.ts
*
* Helpers para construir as respostas MCP das ferramentas de forma
* consistente, tanto de sucesso quanto de erro.
*
* Erros nunca expõem stack trace: retornam um payload estruturado
* com `success: false`, `code` e `message`.
*/
/** Payload de erro estruturado devolvido ao cliente MCP. */
export interface ErrorPayload {
success: false;
error: {
code: string;
message: string;
};
}
/**
* Monta o payload de erro estruturado a partir de uma exceção.
* Erros "Not implemented" são mapeados para o código NOT_IMPLEMENTED;
* demais erros são tratados como erro interno genérico, sem vazar
* detalhes de implementação.
*/
function buildErrorPayload(error: unknown): ErrorPayload {
if (error instanceof Error && error.message === "Not implemented") {
return {
success: false,
error: {
code: "NOT_IMPLEMENTED",
message: "Not implemented",
},
};
}
return {
success: false,
error: {
code: "INTERNAL_ERROR",
message: "Erro interno ao processar a solicitação.",
},
};
}
/** Resposta de sucesso de uma ferramenta MCP. */
export function toTextResponse(data: unknown) {
return {
content: [
{
type: "text" as const,
text: JSON.stringify({ success: true, data }),
},
],
};
}
/** Resposta de erro de uma ferramenta MCP. */
export function toErrorResponse(error: unknown) {
return {
content: [
{
type: "text" as const,
text: JSON.stringify(buildErrorPayload(error)),
},
],
isError: true,
};
}
@@ -0,0 +1,150 @@
/**
* types/scheduling.types.ts
*
* Tipos genéricos de domínio para agendamento em clínicas.
*
* Estes tipos NUNCA devem conter nomes ou conceitos específicos de um
* sistema de clínica em particular (ex.: Z-Pro, Klingo, SysLife, ClinicAll).
* Eles representam o "contrato" comum que todos os Adapters devem respeitar.
*/
/**
* Representa um horário disponível para agendamento.
*/
export interface AvailableSlot {
/**
* Identificador único do horário no sistema de origem.
* Opcional: um provider pode confirmar a criação de um agendamento
* (sucesso HTTP) sem que o adapter consiga determinar o identificador
* do horário — por exemplo, quando o schema de resposta do provider
* ainda não foi verificado. Ver `Appointment.responseMappingPending`.
*/
id?: string;
/** Data e hora de início do horário disponível (ISO 8601). */
startDateTime: string;
/** Data e hora de término do horário disponível (ISO 8601). */
endDateTime: string;
/**
* Identificador do profissional relacionado a este horário.
* Opcional porque nem todo provider informa um profissional associado
* ao agendamento (ex.: Z-PRO não expõe esse conceito no fluxo de
* criação); providers que o suportam devem sempre preenchê-lo.
*/
professionalId?: string;
/** Nome do profissional relacionado a este horário. */
professionalName?: string;
/** Identificador da especialidade/serviço relacionado a este horário. */
specialtyId?: string;
/** Nome da especialidade/serviço relacionado a este horário. */
specialtyName?: string;
}
/**
* Representa um paciente a ser vinculado a um agendamento.
*/
export interface Patient {
/** Identificador único do paciente no sistema de origem, quando existente. */
id?: string;
/** Nome completo do paciente. */
name: string;
/** Documento de identificação do paciente (ex.: CPF). */
document?: string;
/** Telefone de contato do paciente. */
phone?: string;
/** E-mail de contato do paciente. */
email?: string;
}
/**
* Representa um agendamento aceito pelo provider.
*/
export interface Appointment {
/**
* Identificador único do agendamento no sistema de origem.
* Opcional: ausente quando o provider confirma a operação (sucesso
* HTTP), mas o adapter não pode extrair o identificador real da
* resposta sem inventá-lo (ver `responseMappingPending`). Nesse caso, a
* AUSÊNCIA de `id` não significa que a operação falhou.
*/
id?: string;
/** Horário utilizado no agendamento. */
slot: AvailableSlot;
/** Paciente vinculado ao agendamento. */
patient: Patient;
/** Situação atual do agendamento. */
status: AppointmentStatus;
/**
* `true` quando o provider confirmou a operação (resposta de sucesso),
* mas o adapter ainda não consegue mapear o corpo da resposta para este
* tipo genérico (schema de resposta do provider não verificado). Nesse
* caso os demais campos (em especial `id` e `slot.id`) refletem apenas
* o que foi enviado na requisição, não o que o provider de fato
* retornou, e não devem ser tratados como definitivos.
*
* Consumidores (agente/n8n) NÃO devem reenviar/repetir a operação só
* porque este campo está `true` ou porque `id` está ausente — a
* operação já foi aceita pelo provider.
*/
responseMappingPending?: boolean;
}
/**
* Situações possíveis de um agendamento.
*/
export type AppointmentStatus = "confirmed" | "pending" | "cancelled";
/**
* Dados de entrada para consultar horários disponíveis.
*/
export interface QueryAvailableSlotsInput {
/** Identificador da clínica a ser consultada. */
clinicId: string;
/** Identificador do profissional (opcional, filtro). */
professionalId?: string;
/** Identificador da especialidade/serviço (opcional, filtro). */
specialtyId?: string;
/** Data inicial do período de busca (ISO 8601). */
startDate?: string;
/** Data final do período de busca (ISO 8601). */
endDate?: string;
}
/**
* Dados de entrada para realizar um agendamento.
*
* O agendamento é sempre definido por uma data/hora de início e término
* explícitas (`startAt`/`endAt`). `slotId` é opcional e existe apenas para
* providers que expõem uma consulta de disponibilidade e trabalham com a
* seleção de um horário pré-existente; providers que criam o agendamento
* diretamente por data/hora (ex.: Z-PRO) não o utilizam.
*/
export interface CreateAppointmentInput {
/** Identificador da clínica onde o agendamento será realizado. */
clinicId: string;
/**
* Identificador do horário pré-selecionado, quando o provider expõe uma
* consulta de disponibilidade (`consultarHorarios()`). Ausente quando o
* provider não suporta essa consulta.
*/
slotId?: string;
/**
* Identificador da especialidade/procedimento associado ao horário
* pré-selecionado (normalmente copiado de `AvailableSlot.specialtyId`,
* retornado por `consultarHorarios()`). Opcional: só é usado por
* providers cujo agendamento exige explicitamente qual procedimento está
* sendo marcado, além do horário em si.
*/
specialtyId?: string;
/** Data e hora de início do agendamento (ISO 8601). */
startAt: string;
/** Data e hora de término do agendamento (ISO 8601). */
endAt: string;
/**
* Título/assunto do agendamento. Genérico e opcional: destina-se apenas
* a providers cuja API exige um resumo textual do agendamento (ex.:
* Z-PRO). Providers que não usam esse conceito devem ignorá-lo.
*/
title?: string;
/** Dados do paciente a ser agendado. */
patient: Patient;
}
@@ -0,0 +1,52 @@
/**
* test/adapter-factory.test.ts
*
* Testes unitários para a AdapterFactory.
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createSchedulingAdapter } from "../src/adapters/adapter-factory.js";
import { ZProAdapter } from "../src/adapters/zpro/zpro.adapter.js";
import { KlingoAdapter } from "../src/adapters/klingo/klingo.adapter.js";
import type { SchedulingProviderConfig } from "../src/config/config.types.js";
describe("createSchedulingAdapter", () => {
it("cria um ZProAdapter quando o provider é 'zpro'", () => {
const adapter = createSchedulingAdapter({
provider: "zpro",
baseUrl: "https://zpro.example.com",
apiToken: "token-secreto",
apiId: "api-1",
});
assert.ok(adapter instanceof ZProAdapter);
});
it("cria um KlingoAdapter quando o provider é 'klingo'", () => {
const adapter = createSchedulingAdapter({
provider: "klingo",
baseUrl: "https://klingo.example.com",
appToken: "app-token-secreto",
});
assert.ok(adapter instanceof KlingoAdapter);
});
it("rejeita um provider desconhecido", () => {
const unsupported = {
provider: "syslife",
baseUrl: "https://syslife.example.com",
} as unknown as SchedulingProviderConfig;
assert.throws(
() => createSchedulingAdapter(unsupported),
(error: unknown) => {
assert.ok(error instanceof Error);
assert.match(error.message, /não suportado/);
return true;
},
);
});
});
+106
View File
@@ -0,0 +1,106 @@
/**
* test/analyze.test.ts
*
* Testes unitários para a identificação da estrutura de uma resposta JSON.
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { analyzeStructure } from "../src/diagnostics/analyze.js";
describe("analyzeStructure", () => {
it("descreve a raiz quando é um objeto", () => {
const summary = analyzeStructure({
data: [],
message: "ok",
});
assert.equal(summary.rootType, "object");
assert.deepEqual(summary.rootKeys.sort(), ["data", "message"]);
assert.equal(summary.arrayItemCount, undefined);
});
it("descreve um array: quantidade de itens, chaves e tipos do primeiro item", () => {
const summary = analyzeStructure([
{
id: "app-1",
status: "confirmed",
patient: { name: "Maria" },
startAt: "2026-04-15T14:00:00.000Z",
},
]);
assert.equal(summary.rootType, "array");
assert.equal(summary.arrayItemCount, 1);
assert.deepEqual(summary.firstItemKeys.sort(), [
"id",
"patient",
"startAt",
"status",
]);
assert.deepEqual(summary.fieldTypes, {
id: "string",
status: "string",
patient: "object",
startAt: "string",
});
});
it("detecta possíveis campos de paginação", () => {
const summary = analyzeStructure({
page: 1,
limit: 20,
totalCount: 42,
items: [],
});
assert.deepEqual(summary.possiblePaginationFields.sort(), [
"limit",
"page",
"totalCount",
]);
});
it("detecta possíveis campos de identificação", () => {
const summary = analyzeStructure({
id: "x",
appointmentId: "app-1",
uuid: "abc",
createdAt: "2026-04-15T14:00:00.000Z",
});
assert.deepEqual(summary.possibleIdFields.sort(), [
"appointmentId",
"id",
"uuid",
]);
});
it("detecta possíveis campos de data e de status", () => {
const summary = analyzeStructure({
createdAt: "2026-04-15T14:00:00.000Z",
startAt: "2026-04-15T14:00:00.000Z",
status: "pending",
state: "active",
});
assert.deepEqual(summary.possibleDateFields.sort(), [
"createdAt",
"startAt",
]);
assert.deepEqual(summary.possibleStatusFields.sort(), ["state", "status"]);
});
it("não inventa campos que não existem na resposta", () => {
const summary = analyzeStructure({
id: "x",
status: "ok",
});
assert.deepEqual(summary.possiblePaginationFields, []);
assert.deepEqual(summary.possibleDateFields, []);
assert.equal(summary.arrayItemCount, undefined);
assert.deepEqual(summary.firstItemKeys.sort(), ["id", "status"]);
});
});
+172
View File
@@ -0,0 +1,172 @@
/**
* test/config.test.ts
*
* Testes unitários para a validação da configuração carregada por
* variáveis de ambiente (src/config/env.config.ts).
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { loadSchedulingConfig } from "../src/config/env.config.js";
function env(
overrides: Record<string, string | undefined> = {},
): NodeJS.ProcessEnv {
return {
SCHEDULING_PROVIDER: "zpro",
ZPRO_BASE_URL: "https://zpro.example.com",
ZPRO_API_TOKEN: "token-secreto",
ZPRO_API_ID: "api-1",
ZPRO_INSTANCE_ID: "",
...overrides,
} as NodeJS.ProcessEnv;
}
describe("loadSchedulingConfig", () => {
it("carrega uma configuração válida do Z-PRO", () => {
const config = loadSchedulingConfig(
env({ ZPRO_INSTANCE_ID: "instancia-1" }),
);
assert.deepEqual(config, {
provider: "zpro",
baseUrl: "https://zpro.example.com",
apiToken: "token-secreto",
apiId: "api-1",
instanceId: "instancia-1",
});
});
it("define o provider como 'zpro' por padrão", () => {
const withoutProvider = env();
delete withoutProvider.SCHEDULING_PROVIDER;
const config = loadSchedulingConfig(withoutProvider);
assert.equal(config.provider, "zpro");
});
it("trata ZPRO_INSTANCE_ID vazio como ausente", () => {
const config = loadSchedulingConfig(env());
assert.equal(config.instanceId, undefined);
});
it("falha quando ZPRO_BASE_URL está ausente", () => {
const missingBaseUrl = env();
delete missingBaseUrl.ZPRO_BASE_URL;
assert.throws(
() => loadSchedulingConfig(missingBaseUrl),
(error: unknown) => {
assert.ok(error instanceof Error);
assert.match(error.message, /ZPRO_BASE_URL/);
return true;
},
);
});
it("falha quando ZPRO_API_TOKEN está ausente", () => {
const missingToken = env();
delete missingToken.ZPRO_API_TOKEN;
assert.throws(
() => loadSchedulingConfig(missingToken),
(error: unknown) => {
assert.ok(error instanceof Error);
assert.match(error.message, /ZPRO_API_TOKEN/);
return true;
},
);
});
it("falha quando ZPRO_API_ID está ausente", () => {
const missingApiId = env();
delete missingApiId.ZPRO_API_ID;
assert.throws(
() => loadSchedulingConfig(missingApiId),
(error: unknown) => {
assert.ok(error instanceof Error);
assert.match(error.message, /ZPRO_API_ID/);
return true;
},
);
});
it("não expõe o token em mensagens de erro", () => {
const missingBaseUrl = env();
delete missingBaseUrl.ZPRO_BASE_URL;
assert.throws(
() => loadSchedulingConfig(missingBaseUrl),
(error: unknown) => {
assert.ok(error instanceof Error);
assert.ok(!error.message.includes("token-secreto"));
return true;
},
);
});
it("rejeita um provider desconhecido", () => {
assert.throws(
() => loadSchedulingConfig(env({ SCHEDULING_PROVIDER: "syslife" })),
(error: unknown) => {
assert.ok(error instanceof Error);
assert.match(error.message, /SCHEDULING_PROVIDER/);
return true;
},
);
});
it("não exige variáveis ZPRO_* quando o provider é 'klingo'", () => {
const config = loadSchedulingConfig({
SCHEDULING_PROVIDER: "klingo",
KLINGO_BASE_URL: "https://klingo.example.com",
KLINGO_APP_TOKEN: "app-token-secreto",
} as NodeJS.ProcessEnv);
assert.deepEqual(config, {
provider: "klingo",
baseUrl: "https://klingo.example.com",
appToken: "app-token-secreto",
});
});
it("falha quando KLINGO_BASE_URL está ausente com provider 'klingo'", () => {
assert.throws(
() =>
loadSchedulingConfig({
SCHEDULING_PROVIDER: "klingo",
KLINGO_APP_TOKEN: "app-token-secreto",
} as NodeJS.ProcessEnv),
(error: unknown) => {
assert.ok(error instanceof Error);
assert.match(error.message, /KLINGO_BASE_URL/);
return true;
},
);
});
it("falha quando KLINGO_APP_TOKEN está ausente com provider 'klingo'", () => {
assert.throws(
() =>
loadSchedulingConfig({
SCHEDULING_PROVIDER: "klingo",
KLINGO_BASE_URL: "https://klingo.example.com",
} as NodeJS.ProcessEnv),
(error: unknown) => {
assert.ok(error instanceof Error);
assert.match(error.message, /KLINGO_APP_TOKEN/);
return true;
},
);
});
it("não exige variáveis KLINGO_* quando o provider é 'zpro'", () => {
const config = loadSchedulingConfig(env());
assert.equal(config.provider, "zpro");
});
});
@@ -0,0 +1,184 @@
/**
* test/diagnose-client.test.ts
*
* Testes unitários do cliente HTTP de diagnóstico e do tratamento de
* respostas. Nenhum teste chama a API real — todos usam um fetch fake.
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
RequestTimeoutError,
NetworkRequestError,
buildAppointmentListUrl,
fetchAppointmentList,
} from "../src/diagnostics/client.js";
import {
tryParseJson,
buildResponseReport,
} from "../src/diagnostics/response.js";
import type { ZProConfig } from "../src/config/config.types.js";
const config: ZProConfig = {
provider: "zpro",
baseUrl: "https://zpro.example.com",
apiToken: "token-super-secreto",
apiId: "api-1",
};
describe("buildAppointmentListUrl", () => {
it("monta a URL com ApiID e parâmetros conservadores", () => {
const url = buildAppointmentListUrl({
baseUrl: "https://zpro.example.com/",
apiId: "api-1",
});
assert.equal(
url,
"https://zpro.example.com/v2/api/external/api-1/appointment/list?page=1&limit=5",
);
});
it("não inclui o token na URL", () => {
const url = buildAppointmentListUrl(config);
assert.ok(!url.includes(config.apiToken));
});
});
describe("fetchAppointmentList", () => {
it("envia GET com Bearer token e Accept, e retorna os dados da resposta", async () => {
let capturedUrl = "";
let capturedHeaders: Record<string, string> = {};
const fakeFetch = async (
url: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> => {
capturedUrl = String(url);
capturedHeaders = Object.fromEntries(
new Headers(init?.headers).entries(),
);
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "content-type": "application/json" },
});
};
const response = await fetchAppointmentList(config, {
fetchImpl: fakeFetch,
});
assert.equal(capturedUrl, buildAppointmentListUrl(config));
assert.equal(capturedHeaders.authorization, "Bearer token-super-secreto");
assert.equal(capturedHeaders.accept, "application/json");
assert.equal(response.status, 200);
assert.equal(response.contentType, "application/json");
assert.deepEqual(JSON.parse(response.bodyText), { ok: true });
assert.equal(typeof response.durationMs, "number");
});
it("não envia o token em outro header", async () => {
let capturedHeaders: Record<string, string> = {};
const fakeFetch = async (
_url: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> => {
capturedHeaders = Object.fromEntries(
new Headers(init?.headers).entries(),
);
return new Response("", { status: 200 });
};
await fetchAppointmentList(config, { fetchImpl: fakeFetch });
assert.deepEqual(capturedHeaders, {
authorization: `Bearer ${config.apiToken}`,
accept: "application/json",
});
});
it("lança RequestTimeoutError quando a requisição excede o timeout", async () => {
const fakeFetch = async (
_url: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> => {
return new Promise((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => {
reject(new DOMException("The operation was aborted", "AbortError"));
});
});
};
await assert.rejects(
fetchAppointmentList(config, {
timeoutMs: 50,
fetchImpl: fakeFetch,
}),
(error: unknown) => error instanceof RequestTimeoutError,
);
});
it("lança NetworkRequestError em erro de rede, sem expor o token", async () => {
const fakeFetch = async (): Promise<Response> => {
throw new TypeError("fetch failed");
};
await assert.rejects(
fetchAppointmentList(config, { fetchImpl: fakeFetch }),
(error: unknown) => {
assert.ok(error instanceof NetworkRequestError);
assert.ok(!error.message.includes(config.apiToken));
return true;
},
);
});
});
describe("tratamento de resposta", () => {
it("identifica uma resposta JSON", () => {
const parsed = tryParseJson('{"data": [1, 2]}');
assert.deepEqual(parsed, { data: [1, 2] });
});
it("identifica uma resposta não JSON", () => {
const parsed = tryParseJson("<html><body>erro</body></html>");
assert.equal(parsed, null);
});
it("monta relatório para resposta JSON com conteúdo sanitizado e estrutura", () => {
const report = buildResponseReport(
JSON.stringify({
data: [
{
id: "app-1",
patient: { name: "Maria dos Santos", phone: "75999999999" },
status: "confirmed",
},
],
page: 1,
}),
["token-super-secreto"],
);
assert.equal(report.isJson, true);
assert.ok(report.structure);
assert.equal(report.structure.rootType, "object");
assert.equal(report.structure.arrayItemCount, undefined);
assert.ok(!report.sanitizedText.includes("Maria dos Santos"));
assert.ok(!report.sanitizedText.includes("75999999999"));
});
it("monta relatório para resposta não JSON limitado a 500 caracteres", () => {
const longHtml = `<!DOCTYPE html>${"x".repeat(1000)}`;
const report = buildResponseReport(longHtml, []);
assert.equal(report.isJson, false);
assert.equal(report.structure, undefined);
assert.ok(report.sanitizedText.length <= 500);
});
});
@@ -0,0 +1,431 @@
/**
* test/klingo-adapter.test.ts
*
* Testes unitários do KlingoAdapter, com mocks de `fetch` (nenhuma chamada
* de rede real). Espelha o padrão de `test/zpro-adapter.test.ts`.
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { KlingoAdapter } from "../src/adapters/klingo/klingo.adapter.js";
import type { KlingoConfig } from "../src/config/config.types.js";
import type {
CreateAppointmentInput,
QueryAvailableSlotsInput,
} from "../src/types/scheduling.types.js";
const config: KlingoConfig = {
provider: "klingo",
baseUrl: "https://klingo.example.com",
appToken: "app-token-secreto",
};
function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
function rawResponse(
status: number,
body: string | null,
contentType?: string,
): Response {
return new Response(body, {
status,
headers: contentType ? { "content-type": contentType } : undefined,
});
}
const validPatient = {
name: "Paciente Teste",
phone: "11999999999",
document: "12345678900",
};
const baseAgendarInput: CreateAppointmentInput = {
clinicId: "clinica-1",
slotId: "2026-04-20|71|2|1|18:30",
specialtyId: "CONS",
startAt: "2026-04-20T18:30:00.000Z",
endAt: "2026-04-20T19:00:00.000Z",
patient: validPatient,
};
const sessaoBody = {
access_token: "sessao-token-abc",
token_type: "bearer",
expires_in: 360000,
};
describe("KlingoAdapter.consultarHorarios", () => {
it("lança SLOT_MAPPING_UNAVAILABLE quando a API responde com sucesso", async () => {
const calls: { url: string; init: RequestInit }[] = [];
const fetchImpl = (async (url: string, init: RequestInit) => {
calls.push({ url: String(url), init });
return jsonResponse(200, { procedimento: "CONS", horarios: [] });
}) as unknown as typeof fetch;
const adapter = new KlingoAdapter(config, { fetchImpl });
const input: QueryAvailableSlotsInput = {
clinicId: "clinica-1",
specialtyId: "CONS",
};
await assert.rejects(
() => adapter.consultarHorarios(input),
(error: unknown) => {
assert.ok(error instanceof Error);
assert.equal(
(error as { code?: string }).code,
"SLOT_MAPPING_UNAVAILABLE",
);
return true;
},
);
assert.equal(calls.length, 1);
assert.match(calls[0].url, /\/agenda\/horarios\?especialidade=CONS/);
const headers = calls[0].init.headers as Record<string, string>;
assert.equal(headers["X-APP-TOKEN"], "app-token-secreto");
});
it("mapeia HTTP 401 para AUTHENTICATION_ERROR sem tentar interpretar o corpo", async () => {
const fetchImpl = (async () =>
rawResponse(401, "unauthorized")) as unknown as typeof fetch;
const adapter = new KlingoAdapter(config, { fetchImpl });
await assert.rejects(
() => adapter.consultarHorarios({ clinicId: "clinica-1" }),
(error: unknown) => {
assert.ok(error instanceof Error);
assert.equal((error as { code?: string }).code, "AUTHENTICATION_ERROR");
return true;
},
);
});
it("monta os query params a partir do input genérico", async () => {
let capturedUrl = "";
const fetchImpl = (async (url: string) => {
capturedUrl = String(url);
return jsonResponse(200, { horarios: [] });
}) as unknown as typeof fetch;
const adapter = new KlingoAdapter(config, { fetchImpl });
await assert.rejects(() =>
adapter.consultarHorarios({
clinicId: "12345",
professionalId: "CRM-1",
specialtyId: "CONS",
startDate: "2026-04-20",
endDate: "2026-04-25",
}),
);
assert.match(capturedUrl, /especialidade=CONS/);
assert.match(capturedUrl, /crm=CRM-1/);
assert.match(capturedUrl, /cnes=12345/);
assert.match(capturedUrl, /inicio=2026-04-20/);
assert.match(capturedUrl, /fim=2026-04-25/);
});
});
describe("KlingoAdapter.agendar — validação local (sem chamada de rede)", () => {
it("lança VALIDATION_ERROR quando patient.phone está ausente", async () => {
const fetchImpl = (async () => {
throw new Error("não deveria chamar a rede");
}) as unknown as typeof fetch;
const adapter = new KlingoAdapter(config, { fetchImpl });
await assert.rejects(
() =>
adapter.agendar({
...baseAgendarInput,
patient: { name: "Sem Telefone" },
}),
(error: unknown) => {
assert.ok(error instanceof Error);
assert.equal((error as { code?: string }).code, "VALIDATION_ERROR");
assert.match(error.message, /telefone/);
return true;
},
);
});
it("lança VALIDATION_ERROR quando slotId está ausente (após identificar)", async () => {
const fetchImpl = (async () =>
jsonResponse(200, sessaoBody)) as unknown as typeof fetch;
const adapter = new KlingoAdapter(config, { fetchImpl });
await assert.rejects(
() => adapter.agendar({ ...baseAgendarInput, slotId: undefined }),
(error: unknown) => {
assert.ok(error instanceof Error);
assert.equal((error as { code?: string }).code, "VALIDATION_ERROR");
assert.match(error.message, /slotId/);
return true;
},
);
});
it("lança VALIDATION_ERROR quando specialtyId está ausente (após identificar)", async () => {
const fetchImpl = (async () =>
jsonResponse(200, sessaoBody)) as unknown as typeof fetch;
const adapter = new KlingoAdapter(config, { fetchImpl });
await assert.rejects(
() => adapter.agendar({ ...baseAgendarInput, specialtyId: undefined }),
(error: unknown) => {
assert.ok(error instanceof Error);
assert.equal((error as { code?: string }).code, "VALIDATION_ERROR");
assert.match(error.message, /specialtyId/);
return true;
},
);
});
});
describe("KlingoAdapter.agendar — identificação do paciente", () => {
it("envia telefone + cpf (X-APP-TOKEN) e usa o Bearer retornado na reserva", async () => {
const calls: { url: string; init: RequestInit }[] = [];
const fetchImpl = (async (url: string, init: RequestInit) => {
calls.push({ url: String(url), init });
if (String(url).includes("/paciente/identificar")) {
return jsonResponse(200, sessaoBody);
}
return jsonResponse(201, {
id: 555,
data: "2026-04-20",
hora: "18:30",
profissional: { id: 9, nome: "Dr. Teste" },
});
}) as unknown as typeof fetch;
const adapter = new KlingoAdapter(config, { fetchImpl });
await adapter.agendar(baseAgendarInput);
assert.equal(calls.length, 2);
assert.match(calls[0].url, /\/paciente\/identificar$/);
const identHeaders = calls[0].init.headers as Record<string, string>;
assert.equal(identHeaders["X-APP-TOKEN"], "app-token-secreto");
const identBody = JSON.parse(calls[0].init.body as string);
assert.equal(identBody.telefone, "11999999999");
assert.equal(identBody.cpf, "12345678900");
assert.equal(identBody.apenas_telefone, undefined);
assert.match(calls[1].url, /\/agenda\/reservar$/);
const reservarHeaders = calls[1].init.headers as Record<string, string>;
assert.equal(reservarHeaders.Authorization, "Bearer sessao-token-abc");
});
it("usa apenas_telefone quando document está ausente", async () => {
const calls: { url: string; init: RequestInit }[] = [];
const fetchImpl = (async (url: string, init: RequestInit) => {
calls.push({ url: String(url), init });
if (String(url).includes("/paciente/identificar")) {
return jsonResponse(200, sessaoBody);
}
return jsonResponse(201, { id: 1 });
}) as unknown as typeof fetch;
const adapter = new KlingoAdapter(config, { fetchImpl });
await adapter.agendar({
...baseAgendarInput,
patient: { name: "Sem Documento", phone: "11988887777" },
});
const identBody = JSON.parse(calls[0].init.body as string);
assert.equal(identBody.cpf, undefined);
assert.equal(identBody.apenas_telefone, true);
});
for (const status of [401, 403, 404, 400, 422, 500]) {
it(`mapeia HTTP ${status} de identificar() para o código correto`, async () => {
const fetchImpl = (async () =>
rawResponse(status, "erro")) as unknown as typeof fetch;
const adapter = new KlingoAdapter(config, { fetchImpl });
await assert.rejects(
() => adapter.agendar(baseAgendarInput),
(error: unknown) => {
assert.ok(error instanceof Error);
const code = (error as { code?: string }).code;
if (status === 401 || status === 403)
assert.equal(code, "AUTHENTICATION_ERROR");
else if (status === 404) assert.equal(code, "NOT_FOUND");
else if (status === 400 || status === 422)
assert.equal(code, "VALIDATION_ERROR");
else assert.equal(code, "KLINGO_API_ERROR");
return true;
},
);
});
}
it("lança AUTHENTICATION_ERROR quando identificar responde 2xx sem access_token", async () => {
const fetchImpl = (async () =>
jsonResponse(200, { foo: "bar" })) as unknown as typeof fetch;
const adapter = new KlingoAdapter(config, { fetchImpl });
await assert.rejects(
() => adapter.agendar(baseAgendarInput),
(error: unknown) => {
assert.ok(error instanceof Error);
assert.equal((error as { code?: string }).code, "AUTHENTICATION_ERROR");
return true;
},
);
});
it("não vaza o app token nem o access_token em mensagens de erro", async () => {
const fetchImpl = (async () => {
throw new Error("network down");
}) as unknown as typeof fetch;
const adapter = new KlingoAdapter(config, { fetchImpl });
await assert.rejects(
() => adapter.agendar(baseAgendarInput),
(error: unknown) => {
assert.ok(error instanceof Error);
assert.ok(!error.message.includes("app-token-secreto"));
assert.ok(!error.message.includes("sessao-token-abc"));
return true;
},
);
});
});
describe("KlingoAdapter.agendar — reserva (mapeamento de sucesso verificado)", () => {
it("constrói o Appointment a partir da resposta documentada de reserva", async () => {
const fetchImpl = (async (url: string) => {
if (String(url).includes("/paciente/identificar")) {
return jsonResponse(200, sessaoBody);
}
return jsonResponse(201, {
id: 777,
expira: "2026-04-20 18:40",
data: "2026-04-20",
hora: "18:30",
profissional: { id: 42, nome: "Dra. Exemplo" },
});
}) as unknown as typeof fetch;
const adapter = new KlingoAdapter(config, { fetchImpl });
const appointment = await adapter.agendar(baseAgendarInput);
assert.equal(appointment.id, "777");
assert.equal(appointment.status, "confirmed");
assert.equal(appointment.responseMappingPending, undefined);
assert.equal(appointment.slot.professionalId, "42");
assert.equal(appointment.slot.professionalName, "Dra. Exemplo");
assert.equal(appointment.slot.startDateTime, baseAgendarInput.startAt);
assert.equal(appointment.slot.endDateTime, baseAgendarInput.endAt);
assert.deepEqual(appointment.patient, validPatient);
});
it("envia confirmado preenchido (marcação definitiva, não só reserva temporária)", async () => {
const calls: { url: string; init: RequestInit }[] = [];
const fetchImpl = (async (url: string, init: RequestInit) => {
calls.push({ url: String(url), init });
if (String(url).includes("/paciente/identificar")) {
return jsonResponse(200, sessaoBody);
}
return jsonResponse(201, { id: 1 });
}) as unknown as typeof fetch;
const adapter = new KlingoAdapter(config, { fetchImpl });
await adapter.agendar(baseAgendarInput);
const reservarBody = JSON.parse(calls[1].init.body as string);
assert.equal(reservarBody.procedimento, "CONS");
assert.equal(reservarBody.id, baseAgendarInput.slotId);
assert.ok(
typeof reservarBody.confirmado === "string" &&
reservarBody.confirmado.length > 0,
);
});
for (const status of [401, 403, 404, 400, 422, 500]) {
it(`mapeia HTTP ${status} de reservar() para o código correto`, async () => {
const fetchImpl = (async (url: string) => {
if (String(url).includes("/paciente/identificar")) {
return jsonResponse(200, sessaoBody);
}
return rawResponse(status, "erro");
}) as unknown as typeof fetch;
const adapter = new KlingoAdapter(config, { fetchImpl });
await assert.rejects(
() => adapter.agendar(baseAgendarInput),
(error: unknown) => {
assert.ok(error instanceof Error);
const code = (error as { code?: string }).code;
if (status === 401 || status === 403)
assert.equal(code, "AUTHENTICATION_ERROR");
else if (status === 404) assert.equal(code, "NOT_FOUND");
else if (status === 400 || status === 422)
assert.equal(code, "VALIDATION_ERROR");
else assert.equal(code, "KLINGO_API_ERROR");
return true;
},
);
});
}
});
describe("KlingoAdapter.agendar — qualquer HTTP 2xx de reservar é sucesso", () => {
const scenarios: {
name: string;
status: number;
body: string | null;
contentType?: string;
}[] = [
{
name: "200 com JSON sem id numérico",
status: 200,
body: '{"status":"ok"}',
contentType: "application/json",
},
{
name: "200 com texto simples",
status: 200,
body: "OK",
contentType: "text/plain",
},
{ name: "200 com corpo vazio", status: 200, body: "" },
{ name: "204 sem corpo", status: 204, body: null },
{
name: "2xx com JSON malformado",
status: 200,
body: "{invalid",
contentType: "application/json",
},
];
for (const scenario of scenarios) {
it(`${scenario.name}: NÃO lança erro, retorna Appointment com responseMappingPending`, async () => {
const fetchImpl = (async (url: string) => {
if (String(url).includes("/paciente/identificar")) {
return jsonResponse(200, sessaoBody);
}
return rawResponse(
scenario.status,
scenario.body,
scenario.contentType,
);
}) as unknown as typeof fetch;
const adapter = new KlingoAdapter(config, { fetchImpl });
const appointment = await adapter.agendar(baseAgendarInput);
assert.equal(appointment.responseMappingPending, true);
assert.equal(appointment.status, "pending");
assert.equal(appointment.id, undefined);
assert.equal(appointment.slot.startDateTime, baseAgendarInput.startAt);
assert.deepEqual(appointment.patient, validPatient);
});
}
});
+129
View File
@@ -0,0 +1,129 @@
/**
* test/sanitize.test.ts
*
* Testes unitários para a sanitização recursiva de respostas.
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
REDACTED,
isSensitiveField,
sanitizeRecursively,
sanitizeText,
} from "../src/diagnostics/sanitize.js";
describe("isSensitiveField", () => {
it("reconhece nomes de campos sensíveis ignorando maiúsculas/minúsculas", () => {
assert.equal(isSensitiveField("name"), true);
assert.equal(isSensitiveField("patientName"), true);
assert.equal(isSensitiveField("NOME"), true);
assert.equal(isSensitiveField("phone"), true);
assert.equal(isSensitiveField("telefone"), true);
assert.equal(isSensitiveField("email"), true);
assert.equal(isSensitiveField("cpf"), true);
assert.equal(isSensitiveField("document"), true);
assert.equal(isSensitiveField("documento"), true);
assert.equal(isSensitiveField("token"), true);
assert.equal(isSensitiveField("authorization"), true);
assert.equal(isSensitiveField("password"), true);
assert.equal(isSensitiveField("senha"), true);
assert.equal(isSensitiveField("address"), true);
assert.equal(isSensitiveField("endereco"), true);
});
it("não reconhece campos não sensíveis", () => {
assert.equal(isSensitiveField("id"), false);
assert.equal(isSensitiveField("appointmentId"), false);
assert.equal(isSensitiveField("startAt"), false);
assert.equal(isSensitiveField("status"), false);
assert.equal(isSensitiveField("page"), false);
});
});
describe("sanitizeRecursively", () => {
it("mascara valores de campos sensíveis", () => {
const sanitized = sanitizeRecursively({
name: "Maria dos Santos",
phone: "75999999999",
email: "maria@example.com",
});
assert.equal(sanitized.name, REDACTED);
assert.equal(sanitized.phone, REDACTED);
assert.equal(sanitized.email, REDACTED);
});
it("percorre objetos e arrays aninhados", () => {
const sanitized = sanitizeRecursively({
data: [
{
patient: { name: "João", cpf: "12345678900" },
appointmentId: "abc-123",
},
],
});
assert.equal(sanitized.data[0].patient.name, REDACTED);
assert.equal(sanitized.data[0].patient.cpf, REDACTED);
assert.equal(sanitized.data[0].appointmentId, "abc-123");
});
it("mantém campos não sensíveis intactos", () => {
const sanitized = sanitizeRecursively({
id: "app-1",
status: "confirmed",
page: 1,
});
assert.equal(sanitized.id, "app-1");
assert.equal(sanitized.status, "confirmed");
assert.equal(sanitized.page, 1);
});
it("redige ocorrências de segredos mesmo em campos não sensíveis", () => {
const token = "segredo-super-secreto";
const sanitized = sanitizeRecursively(
{
message: `token=${token}`,
data: "x",
},
[token],
);
assert.ok(!JSON.stringify(sanitized).includes(token));
assert.ok(JSON.stringify(sanitized).includes(REDACTED));
});
it("não vaza o token na saída sanitizada", () => {
const token = "token-real-xyz";
const sanitized = sanitizeRecursively(
{
data: [{ patient: { name: "Maria", phone: "75999999999" }, token }],
},
[token],
);
assert.ok(!JSON.stringify(sanitized).includes(token));
assert.ok(!JSON.stringify(sanitized).includes("75999999999"));
assert.ok(!JSON.stringify(sanitized).includes("Maria"));
});
it("lida com valores primitivos e null", () => {
assert.equal(sanitizeRecursively(null), null);
assert.equal(sanitizeRecursively("texto", ["zzz"]), "texto");
assert.equal(sanitizeRecursively(42), 42);
});
});
describe("sanitizeText", () => {
it("redige segredos e limita o comprimento", () => {
const long = "a".repeat(700);
const result = sanitizeText(`token=${long}`, [long]);
assert.ok(!result.includes(long));
assert.ok(result.length <= 500);
assert.ok(result.includes(REDACTED));
});
});
+100
View File
@@ -0,0 +1,100 @@
/**
* test/tools.test.ts
*
* Testes unitários para o tratamento de erros das ferramentas MCP.
*
* Os handlers devem capturar qualquer erro lançado pelo Adapter e devolver
* uma resposta MCP estruturada, sem encerrar o servidor e sem vazar
* detalhes de implementação (stack trace, nomes de classe internos,
* token). Testes de comportamento específico do ZProAdapter (mapeamento
* de requisição, classificação de erros HTTP) ficam em
* `test/zpro-adapter.test.ts`; aqui testamos apenas a camada da tool.
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { ZProAdapter } from "../src/adapters/zpro/zpro.adapter.js";
import { createConsultarHorariosHandler } from "../src/tools/consultar-horarios.tool.js";
import { createAgendarHandler } from "../src/tools/agendar.tool.js";
import type { ZProConfig } from "../src/config/config.types.js";
const config: ZProConfig = {
provider: "zpro",
baseUrl: "https://zpro.example.com",
apiToken: "token-secreto",
apiId: "api-1",
};
describe("consultar_horarios", () => {
it("retorna resposta de erro estruturada quando o Z-PRO não suporta a consulta", async () => {
const adapter = new ZProAdapter(config);
const handler = createConsultarHorariosHandler(adapter);
const result = await handler({ clinicId: "clinic-1" });
const payload = JSON.parse(result.content[0].text);
assert.equal(payload.success, false);
assert.equal(payload.error.code, "INTERNAL_ERROR");
});
it("não retorna stack trace nem nomes de classe internos na resposta", async () => {
const adapter = new ZProAdapter(config);
const handler = createConsultarHorariosHandler(adapter);
const result = await handler({ clinicId: "clinic-1" });
assert.ok(!result.content[0].text.includes("at "));
assert.ok(!result.content[0].text.includes("ZProAdapter"));
assert.ok(!result.content[0].text.includes("ZProApiError"));
});
});
describe("agendar", () => {
const failingFetch = async (): Promise<Response> => {
throw new TypeError("fetch failed");
};
const agendarInput = {
clinicId: "clinic-1",
startAt: "2026-04-15T14:00:00.000Z",
endAt: "2026-04-15T15:00:00.000Z",
patient: { name: "Paciente Exemplo" },
};
it("retorna resposta de erro estruturada quando a chamada ao Z-PRO falha", async () => {
const adapter = new ZProAdapter(config, { fetchImpl: failingFetch });
const handler = createAgendarHandler(adapter);
const result = await handler(agendarInput);
const payload = JSON.parse(result.content[0].text);
assert.equal(payload.success, false);
assert.equal(payload.error.code, "INTERNAL_ERROR");
});
it("não vaza o token do Z-PRO na resposta de erro", async () => {
const adapter = new ZProAdapter(config, { fetchImpl: failingFetch });
const handler = createAgendarHandler(adapter);
const result = await handler(agendarInput);
assert.ok(!result.content[0].text.includes(config.apiToken));
});
it("retorna success:true quando o Z-PRO confirma a criação (HTTP 2xx) — não deve parecer falha", async () => {
const acceptingFetch = async (): Promise<Response> =>
new Response(JSON.stringify({ id: "algo" }), {
status: 201,
headers: { "content-type": "application/json" },
});
const adapter = new ZProAdapter(config, { fetchImpl: acceptingFetch });
const handler = createAgendarHandler(adapter);
const result = await handler(agendarInput);
const payload = JSON.parse(result.content[0].text);
assert.equal(payload.success, true);
assert.equal(payload.data.responseMappingPending, true);
assert.notEqual(result.isError, true);
});
});
@@ -0,0 +1,570 @@
/**
* test/zpro-adapter.test.ts
*
* Testes unitários do ZProAdapter real (agendar/consultarHorarios).
* Todos usam um fetch fake injetado via `fetchImpl` — nenhum teste chama
* a API real do Z-PRO.
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { ZProAdapter } from "../src/adapters/zpro/zpro.adapter.js";
import {
buildAppointmentCreateUrl,
describeResponseMetadata,
} from "../src/adapters/zpro/zpro-http-client.js";
import { ZProApiError } from "../src/adapters/zpro/zpro.errors.js";
import type { ZProConfig } from "../src/config/config.types.js";
import type { CreateAppointmentInput } from "../src/types/scheduling.types.js";
const config: ZProConfig = {
provider: "zpro",
baseUrl: "https://zpro.example.com",
apiToken: "token-super-secreto",
apiId: "api-1",
};
const baseInput: CreateAppointmentInput = {
clinicId: "clinic-1",
startAt: "2026-04-15T14:00:00.000Z",
endAt: "2026-04-15T15:00:00.000Z",
patient: { name: "Paciente Exemplo" },
};
function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
async function expectCode(
promise: Promise<unknown>,
code: string,
): Promise<void> {
await assert.rejects(promise, (error: unknown) => {
assert.ok(error instanceof ZProApiError);
assert.equal(error.code, code);
return true;
});
}
describe("ZProAdapter.consultarHorarios", () => {
it("lança ZProApiError com código NOT_SUPPORTED, sem chamar a rede", async () => {
let called = false;
const fetchImpl = async (): Promise<Response> => {
called = true;
return jsonResponse(200, {});
};
const adapter = new ZProAdapter(config, { fetchImpl });
await expectCode(
adapter.consultarHorarios({ clinicId: "clinic-1" }),
"NOT_SUPPORTED",
);
assert.equal(called, false);
});
});
describe("ZProAdapter.agendar — montagem da requisição", () => {
it("envia POST para a URL correta, com Bearer token e corpo mapeado", async () => {
let capturedUrl = "";
let capturedMethod = "";
let capturedHeaders: Record<string, string> = {};
let capturedBody: unknown;
const fetchImpl = async (
url: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> => {
capturedUrl = String(url);
capturedMethod = init?.method ?? "";
capturedHeaders = Object.fromEntries(
new Headers(init?.headers).entries(),
);
capturedBody = JSON.parse(String(init?.body));
return jsonResponse(201, { ok: true });
};
const adapter = new ZProAdapter(config, { fetchImpl });
await adapter.agendar({
...baseInput,
patient: { id: "contact-1", name: "Maria Souza", phone: "5511999999999" },
});
assert.equal(capturedUrl, buildAppointmentCreateUrl(config));
assert.equal(capturedMethod, "POST");
assert.equal(capturedHeaders.authorization, "Bearer token-super-secreto");
assert.equal(capturedHeaders["content-type"], "application/json");
assert.deepEqual(capturedBody, {
title: "Consulta - Maria Souza",
contactId: "contact-1",
contactName: "Maria Souza",
contactPhone: "5511999999999",
startAt: baseInput.startAt,
endAt: baseInput.endAt,
status: "pending",
});
});
it("usa o título informado pelo chamador quando presente", async () => {
let capturedBody: unknown;
const fetchImpl = async (
_url: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> => {
capturedBody = JSON.parse(String(init?.body));
return jsonResponse(201, { ok: true });
};
const adapter = new ZProAdapter(config, { fetchImpl });
await adapter.agendar({ ...baseInput, title: "Retorno - Dr. Alex" });
assert.equal(
(capturedBody as { title: string }).title,
"Retorno - Dr. Alex",
);
});
it("omite contactId e contactPhone quando o paciente não os informa", async () => {
let capturedBody: unknown;
const fetchImpl = async (
_url: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> => {
capturedBody = JSON.parse(String(init?.body));
return jsonResponse(201, { ok: true });
};
const adapter = new ZProAdapter(config, { fetchImpl });
await adapter.agendar(baseInput);
assert.ok(!("contactId" in (capturedBody as object)));
assert.ok(!("contactPhone" in (capturedBody as object)));
});
it("não inclui o token em nenhum outro header", async () => {
let capturedHeaders: Record<string, string> = {};
const fetchImpl = async (
_url: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> => {
capturedHeaders = Object.fromEntries(
new Headers(init?.headers).entries(),
);
return jsonResponse(201, { ok: true });
};
const adapter = new ZProAdapter(config, { fetchImpl });
await adapter.agendar(baseInput);
assert.deepEqual(Object.keys(capturedHeaders).sort(), [
"accept",
"authorization",
"content-type",
]);
});
});
describe("ZProAdapter.agendar — classificação de erros HTTP", () => {
const cases: Array<{ status: number; code: string }> = [
{ status: 401, code: "AUTHENTICATION_ERROR" },
{ status: 403, code: "AUTHENTICATION_ERROR" },
{ status: 404, code: "NOT_FOUND" },
{ status: 400, code: "VALIDATION_ERROR" },
{ status: 422, code: "VALIDATION_ERROR" },
{ status: 500, code: "ZPRO_API_ERROR" },
];
for (const { status, code } of cases) {
it(`mapeia HTTP ${status} para ${code}`, async () => {
const fetchImpl = async (): Promise<Response> =>
jsonResponse(status, { message: "erro" });
const adapter = new ZProAdapter(config, { fetchImpl });
await expectCode(adapter.agendar(baseInput), code);
});
}
it("mapeia timeout para ZPRO_TIMEOUT", async () => {
const fetchImpl = async (
_url: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> => {
return new Promise((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => {
reject(new DOMException("The operation was aborted", "AbortError"));
});
});
};
const adapter = new ZProAdapter(config, { fetchImpl, timeoutMs: 50 });
await expectCode(adapter.agendar(baseInput), "ZPRO_TIMEOUT");
});
it("mapeia erro de rede para ZPRO_API_ERROR, sem vazar o token", async () => {
const fetchImpl = async (): Promise<Response> => {
throw new TypeError("fetch failed");
};
const adapter = new ZProAdapter(config, { fetchImpl });
await assert.rejects(adapter.agendar(baseInput), (error: unknown) => {
assert.ok(error instanceof ZProApiError);
assert.equal(error.code, "ZPRO_API_ERROR");
assert.ok(!error.message.includes(config.apiToken));
return true;
});
});
it("não inclui o token em nenhuma mensagem de erro (todos os cenários)", async () => {
const scenarios: Array<() => Promise<Response>> = [
async () => jsonResponse(401, {}),
async () => jsonResponse(500, {}),
async () => {
throw new TypeError("fetch failed");
},
];
for (const fetchImpl of scenarios) {
const adapter = new ZProAdapter(config, { fetchImpl });
await assert.rejects(adapter.agendar(baseInput), (error: unknown) => {
assert.ok(error instanceof Error);
assert.ok(!error.message.includes(config.apiToken));
return true;
});
}
});
});
describe("ZProAdapter.agendar — sucesso confirmado pela API (HTTP 2xx)", () => {
it("sinaliza responseMappingPending, sem extrair nenhum campo da resposta", async () => {
const fetchImpl = async (): Promise<Response> =>
// Corpo da resposta deliberadamente com campos que poderiam parecer
// um id/status "óbvios" — o teste garante que NADA disso é lido.
jsonResponse(201, {
id: "id-do-zpro-nao-deve-aparecer",
status: "confirmed",
});
const adapter = new ZProAdapter(config, { fetchImpl });
const appointment = await adapter.agendar(baseInput);
assert.equal(appointment.responseMappingPending, true);
assert.equal(appointment.id, undefined);
assert.equal(appointment.slot.id, undefined);
// O status é o que FOI ENVIADO ("pending"), não o que a API retornou
// ("confirmed") — não lemos o corpo da resposta.
assert.equal(appointment.status, "pending");
});
it("constrói slot/patient apenas a partir do input (nunca da resposta)", async () => {
const fetchImpl = async (): Promise<Response> =>
jsonResponse(201, { anything: true });
const adapter = new ZProAdapter(config, { fetchImpl });
const input: CreateAppointmentInput = {
...baseInput,
patient: { id: "contact-1", name: "Maria Souza", phone: "5511999999999" },
};
const appointment = await adapter.agendar(input);
assert.deepEqual(appointment.slot, {
startDateTime: input.startAt,
endDateTime: input.endAt,
});
assert.deepEqual(appointment.patient, input.patient);
});
});
describe("ZProAdapter.agendar — mapeamento real da resposta (envelope verificado)", () => {
it("mapeia id/startAt/endAt/status reais quando o corpo bate com o envelope verificado", async () => {
const fetchImpl = async (): Promise<Response> =>
jsonResponse(201, {
success: true,
data: {
id: 2,
title: "TESTE-MCP-2026-04-20-001",
contactName: "Paciente Teste MCP",
startAt: "2026-04-20T14:00:00.000Z",
endAt: "2026-04-20T14:30:00.000Z",
status: "pending",
createdAt: "2026-08-09T20:32:06.426Z",
},
});
const adapter = new ZProAdapter(config, { fetchImpl });
const appointment = await adapter.agendar(baseInput);
assert.equal(appointment.id, "2");
assert.equal(appointment.responseMappingPending, undefined);
assert.equal(appointment.status, "pending");
assert.deepEqual(appointment.slot, {
startDateTime: "2026-04-20T14:00:00.000Z",
endDateTime: "2026-04-20T14:30:00.000Z",
});
// patient continua vindo do input, nunca do corpo da resposta.
assert.deepEqual(appointment.patient, baseInput.patient);
});
it("mapeia status 'confirmed' e 'cancelled' quando retornados", async () => {
for (const status of ["confirmed", "cancelled"] as const) {
const fetchImpl = async (): Promise<Response> =>
jsonResponse(200, {
success: true,
data: {
id: 9,
startAt: baseInput.startAt,
endAt: baseInput.endAt,
status,
},
});
const adapter = new ZProAdapter(config, { fetchImpl });
const appointment = await adapter.agendar(baseInput);
assert.equal(appointment.status, status);
}
});
it("cai em 'pending' para um status desconhecido (nunca inventa 'confirmed')", async () => {
const fetchImpl = async (): Promise<Response> =>
jsonResponse(200, {
success: true,
data: {
id: 9,
startAt: baseInput.startAt,
endAt: baseInput.endAt,
status: "completed",
},
});
const adapter = new ZProAdapter(config, { fetchImpl });
const appointment = await adapter.agendar(baseInput);
assert.equal(appointment.status, "pending");
});
it("cai no mapeamento pendente quando success:true mas data.id não é numérico", async () => {
const fetchImpl = async (): Promise<Response> =>
jsonResponse(200, {
success: true,
data: {
id: "não-numérico",
startAt: baseInput.startAt,
endAt: baseInput.endAt,
status: "pending",
},
});
const adapter = new ZProAdapter(config, { fetchImpl });
const appointment = await adapter.agendar(baseInput);
assert.equal(appointment.responseMappingPending, true);
assert.equal(appointment.id, undefined);
});
it("cai no mapeamento pendente quando success:false", async () => {
const fetchImpl = async (): Promise<Response> =>
// HTTP 2xx com success:false no corpo é incomum, mas o status HTTP
// continua sendo a fonte da verdade sobre erro/sucesso operacional
// (ver docblock do adapter) — o corpo só é usado para enriquecer o
// mapeamento quando bate exatamente com o envelope esperado.
jsonResponse(200, { success: false, error: "algo não documentado" });
const adapter = new ZProAdapter(config, { fetchImpl });
const appointment = await adapter.agendar(baseInput);
assert.equal(appointment.responseMappingPending, true);
});
});
/**
* Cobertura explícita pedida: qualquer HTTP 2xx deve ser tratado como
* sucesso operacional, independentemente do formato/ausência do corpo, e
* um erro não-2xx deve continuar sendo tratado como erro.
*/
describe("ZProAdapter.agendar — qualquer HTTP 2xx é sucesso, seja qual for o corpo", () => {
const scenarios: Array<{
label: string;
status: number;
contentType: string | undefined;
body: string;
}> = [
{
label: "200 com JSON",
status: 200,
contentType: "application/json",
body: JSON.stringify({ id: "x", status: "confirmed" }),
},
{
label: "200 com texto simples",
status: 200,
contentType: "text/plain",
body: "OK",
},
{
label: "200 com corpo vazio",
status: 200,
contentType: "application/json",
body: "",
},
{
label: "201 com JSON",
status: 201,
contentType: "application/json",
body: JSON.stringify({ appointmentId: 123 }),
},
{
label: "204 sem corpo",
status: 204,
contentType: undefined,
body: "",
},
{
label: "2xx com Content-Type application/json e JSON malformado",
status: 200,
contentType: "application/json",
body: "{ isto não é json válido",
},
];
for (const scenario of scenarios) {
it(`${scenario.label}: NÃO lança erro e retorna Appointment com responseMappingPending`, async () => {
const fetchImpl = async (): Promise<Response> =>
// `Response` nativo não aceita corpo (nem string vazia) em status
// "null body" como 204 — usar `null` nesses casos.
new Response(scenario.body === "" ? null : scenario.body, {
status: scenario.status,
headers: scenario.contentType
? { "content-type": scenario.contentType }
: {},
});
const adapter = new ZProAdapter(config, { fetchImpl });
// Se agendar() lançar aqui, o teste falha — é o comportamento antigo
// que este requisito elimina.
const appointment = await adapter.agendar(baseInput);
assert.equal(appointment.responseMappingPending, true);
assert.equal(appointment.id, undefined);
assert.equal(appointment.status, "pending");
assert.deepEqual(appointment.slot, {
startDateTime: baseInput.startAt,
endDateTime: baseInput.endAt,
});
});
}
it("erro não-2xx continua sendo tratado como erro (sem regressão)", async () => {
const fetchImpl = async (): Promise<Response> =>
jsonResponse(500, { message: "erro interno do Z-PRO" });
const adapter = new ZProAdapter(config, { fetchImpl });
await expectCode(adapter.agendar(baseInput), "ZPRO_API_ERROR");
});
});
describe("describeResponseMetadata — metadados seguros de diagnóstico", () => {
it("classifica corpo vazio", () => {
const metadata = describeResponseMetadata({
status: 204,
contentType: null,
bodyText: "",
});
assert.deepEqual(metadata, {
status: 204,
contentType: null,
hasBody: false,
bodyFormat: "empty",
});
});
it("classifica corpo JSON válido", () => {
const metadata = describeResponseMetadata({
status: 201,
contentType: "application/json",
bodyText: JSON.stringify({ patient: { name: "Nome Sensível" } }),
});
assert.equal(metadata.bodyFormat, "json");
assert.equal(metadata.hasBody, true);
// Nenhum dado do corpo (ex.: nome do paciente) vaza para os metadados.
assert.ok(!JSON.stringify(metadata).includes("Nome Sensível"));
});
it("classifica corpo não JSON (texto simples ou JSON malformado)", () => {
assert.equal(
describeResponseMetadata({
status: 200,
contentType: "text/plain",
bodyText: "OK",
}).bodyFormat,
"non-json",
);
assert.equal(
describeResponseMetadata({
status: 200,
contentType: "application/json",
bodyText: "{ inválido",
}).bodyFormat,
"non-json",
);
});
});
describe("ZProAdapter — hook onResponseMetadata (diagnóstico opcional)", () => {
it("recebe apenas metadados seguros, nunca o corpo, token ou dados do paciente", async () => {
const fetchImpl = async (): Promise<Response> =>
new Response(
JSON.stringify({ id: "x", patient: { name: "Nome Sensível" } }),
{
status: 201,
headers: { "content-type": "application/json" },
},
);
let received: unknown;
const adapter = new ZProAdapter(config, {
fetchImpl,
onResponseMetadata: (metadata) => {
received = metadata;
},
});
await adapter.agendar(baseInput);
assert.deepEqual(received, {
status: 201,
contentType: "application/json",
hasBody: true,
bodyFormat: "json",
});
assert.ok(!JSON.stringify(received).includes("Nome Sensível"));
assert.ok(!JSON.stringify(received).includes(config.apiToken));
});
it("também é chamado em respostas de erro (não só em sucesso)", async () => {
const fetchImpl = async (): Promise<Response> =>
jsonResponse(500, { message: "erro" });
let called = false;
const adapter = new ZProAdapter(config, {
fetchImpl,
onResponseMetadata: () => {
called = true;
},
});
await assert.rejects(adapter.agendar(baseInput));
assert.equal(called, true);
});
it("por padrão (sem o hook), não afeta o comportamento normal", async () => {
const fetchImpl = async (): Promise<Response> =>
jsonResponse(201, { id: "x" });
const adapter = new ZProAdapter(config, { fetchImpl });
const appointment = await adapter.agendar(baseInput);
assert.equal(appointment.responseMappingPending, true);
});
});
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true
},
"include": ["src"]
}