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
@@ -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;
};
}