conflaro-mcp 0.0.0-stage → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +118 -2
  2. package/dist/index.js +847 -0
  3. package/package.json +33 -4
package/README.md CHANGED
@@ -1,3 +1,119 @@
1
- # Temporary Holding Version
1
+ # conflaro-mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Servidor [MCP](https://modelcontextprotocol.io) da Conflaro (ex-`@conttrole/mcp`) — expõe a API
4
+ pública de emissão fiscal (NF-e, NFC-e, NFS-e) como ferramentas para agentes de
5
+ IA (Claude Desktop, Cursor, Claude Code).
6
+
7
+ Roda **localmente** (stdio) — não precisa hospedar nada. Autentica com a sua
8
+ chave de API e fala com `https://api.conflaro.com` por HTTPS.
9
+
10
+ ## Uso
11
+
12
+ ```json
13
+ {
14
+ "mcpServers": {
15
+ "conflaro": {
16
+ "command": "npx",
17
+ "args": ["-y", "conflaro-mcp"],
18
+ "env": { "CONFLARO_API_KEY": "ck_live_xxxxxxxx" }
19
+ }
20
+ }
21
+ }
22
+ ```
23
+
24
+ | Variável | Obrigatória | Default |
25
+ | --- | --- | --- |
26
+ | `CONFLARO_API_KEY` | sim | — (`ck_live_...` da empresa, ou `ok_live_...` do escritório no modo contador) |
27
+ | `CONFLARO_API_URL` | não | `https://api.conflaro.com` |
28
+
29
+ `CONTTROLE_API_KEY`/`CONTTROLE_API_URL` continuam aceitas (nome antigo do pacote).
30
+
31
+ ## Desenvolvimento
32
+
33
+ ```bash
34
+ pnpm install
35
+ pnpm build # compila para dist/
36
+ pnpm dev # roda do fonte (tsx)
37
+ ```
38
+
39
+ Ferramentas (as MESMAS do servidor remoto — ver o guard em
40
+ `__tests__/services/mcp-superficie-unica.test.ts`):
41
+
42
+ - **Empresa**: `get_company`
43
+ - **Documentos fiscais**: `list_fiscal_documents`, `get_fiscal_document`,
44
+ `create_fiscal_document`, `emit_fiscal_document`, `cancel_fiscal_document`,
45
+ `correct_fiscal_document`, `get_fiscal_document_xml`
46
+ - **GNRE** (guias de DIFAL/FCP/ICMS-ST para outra UF): `get_fiscal_document_gnre`,
47
+ `generate_fiscal_document_gnre`, `refresh_fiscal_document_gnre`,
48
+ `list_gnre_guides` — `documents:read`/`documents:write`; gerar e listar exigem
49
+ o módulo **GNRE** contratado
50
+ - **Produtos**: `list_products`, `get_product`, `create_product`,
51
+ `update_product`
52
+ - **Financeiro (AP/AR)**: `list_finance_titles`, `get_finance_title`,
53
+ `get_finance_summary`, `create_finance_title`, `settle_finance_installment`,
54
+ `list_overdue_receivables`, `top_delinquent_clients`, `cashflow_projection`
55
+ — exigem o escopo `finance:read`/`finance:write` na chave e o módulo
56
+ **Financeiro** contratado
57
+ - **Notas recebidas (DF-e)**: `list_received_documents`,
58
+ `get_received_document`, `get_received_document_xml`,
59
+ `manifest_received_document`, `sync_received_documents`,
60
+ `get_received_documents_distribution`, `get_received_documents_export_link`
61
+ (o mês inteiro num ZIP, entregue como link de 15 min) — exigem `received-documents:read`
62
+ (e `:write` para manifestar); com a chave do escritório, informe `companyId`
63
+ - **Simples Nacional**: `get_simples_estimate` — a estimativa do DAS do mês,
64
+ com a memória de cálculo; exige `simples:read`. Com `companyId` (chave do
65
+ escritório), de uma empresa da carteira; `simulate_simples_ibscbs_regime` (IBS/CBS
66
+ dentro × fora do DAS a partir de 2027), `get_simples_das_forecast` (previsão
67
+ do mês e da faixa seguinte) e `get_product_tax_in_price` (imposto no preço e
68
+ preço mínimo)
69
+ - **Notas suspeitas**: `list_suspicious_received_documents` — NF-e recebidas
70
+ de fornecedor novo com valor ou mercadoria fora do padrão (exige
71
+ `received-documents:read`)
72
+ - **Modo contador** (chave do escritório `ok_`): `get_accounting_office`,
73
+ `list_portfolio_companies`, `list_portfolio_das_estimates`, `list_closing_eligible_companies`,
74
+ `request_fiscal_closings`, `list_fiscal_closings`,
75
+ `get_fiscal_closing_download`
76
+ - **Regras e webhooks**: `list_tax_rules`, `list_webhooks`, `create_webhook`
77
+
78
+ Documentação completa: https://docs.conttrole.io/mcp
79
+
80
+ ## Publicação no registro oficial do MCP
81
+
82
+ O `server.json` desta pasta descreve o servidor para o
83
+ [registro oficial](https://registry.modelcontextprotocol.io) com as duas formas
84
+ de uso: este pacote (stdio) e o servidor remoto (`https://mcp.conttrole.io/mcp`,
85
+ OAuth). O nome é `io.conttrole/mcp`, e o namespace `io.conttrole` é provado
86
+ por um registro TXT no DNS de `conttrole.io`.
87
+
88
+ **Quem publica é o CI** (`.github/workflows/mcp-publish.yml`): basta subir a
89
+ versão e commitar na main. Ele publica no npm e depois no registro, e pula o
90
+ que já estiver lá — então rodar de novo depois de uma falha completa o que
91
+ faltou, sem erro de versão duplicada.
92
+
93
+ 🔴 **A versão mora em TRÊS lugares** que precisam bater: o `version` do
94
+ `package.json` e os dois `version` do `server.json`. O guard
95
+ `apps/web/__tests__/services/mcp-registro-versao.test.ts` e o próprio workflow
96
+ recusam quando divergem. O servidor lê a dele do `package.json`.
97
+
98
+ O npm publica por **Trusted Publisher** (OIDC): o vínculo é com
99
+ `saas-nfe/conttrole` + `mcp-publish.yml`, configurado nas Settings do pacote no
100
+ npmjs.com. Renomear o workflow quebra a publicação.
101
+ 🔴 Pacote novo não aceita Trusted Publisher antes de existir: a 1ª versão do
102
+ `conflaro-mcp` sai pelo secret `NPM_TOKEN`, que depois é apagado (ver o
103
+ cabeçalho do `mcp-publish.yml`). O único secret é
104
+ `MCP_REGISTRY_PRIVATE_KEY` (a chave Ed25519 do namespace, em hex); o par
105
+ público dela é o TXT na raiz de `conttrole.io`, e trocar a chave exige trocar
106
+ o TXT junto.
107
+
108
+ Publicação à mão, só se o CI estiver fora (o `mcp-publisher` é o binário das
109
+ releases de `modelcontextprotocol/registry`, **não** o pacote npm homônimo,
110
+ que é um servidor MCP e fica parado esperando no stdin):
111
+
112
+ ```bash
113
+ pnpm --filter conflaro-mcp publish
114
+ mcp-publisher login dns --domain conttrole.io \
115
+ --private-key "$(openssl pkey -in ~/.config/conttrole/mcp-key.pem -noout -text | grep -A3 'priv:' | tail -n +2 | tr -d ' :\n')"
116
+ mcp-publisher publish # de dentro de apps/mcp
117
+ ```
118
+
119
+ Conferência: `curl "https://registry.modelcontextprotocol.io/v0/servers?search=io.conttrole"`.
package/dist/index.js ADDED
@@ -0,0 +1,847 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Servidor MCP da Conflaro — expõe a API pública de emissão fiscal como
4
+ * ferramentas para agentes de IA (Claude Desktop, Cursor, etc.).
5
+ *
6
+ * Configuração via variáveis de ambiente:
7
+ * - CONFLARO_API_KEY (obrigatório) Chave de API da empresa (ck_live_...) ou,
8
+ * no modo contador, do escritório (ok_live_...)
9
+ * - CONFLARO_API_URL (opcional) Default: https://api.conflaro.com
10
+ *
11
+ * 🔴 `CONTTROLE_API_KEY`/`CONTTROLE_API_URL` continuam aceitas: o pacote era
12
+ * `@conttrole/mcp`, e quem migrar para `conflaro-mcp` só trocando o nome no
13
+ * `args` do Claude Desktop não pode ficar sem servidor por causa do `env`.
14
+ *
15
+ * Autenticação na API: header `Authorization: Bearer <chave>`.
16
+ */
17
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
18
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
19
+ import { createRequire } from "node:module";
20
+ import { z } from "zod";
21
+ const API_URL = (process.env.CONFLARO_API_URL ??
22
+ process.env.CONTTROLE_API_URL ??
23
+ "https://api.conflaro.com").replace(/\/$/, "");
24
+ const API_KEY = process.env.CONFLARO_API_KEY ?? process.env.CONTTROLE_API_KEY ?? "";
25
+ if (!API_KEY) {
26
+ console.error("[conflaro-mcp] CONFLARO_API_KEY não definida. " +
27
+ "Gere uma chave em /dashboard/api-keys e configure no ambiente.");
28
+ process.exit(1);
29
+ }
30
+ /** Chama a API pública. Retorna o corpo (JSON) ou texto (XML). Lança em erro. */
31
+ async function api(method, path, opts = {}) {
32
+ const url = new URL(`${API_URL}${path}`);
33
+ for (const [k, v] of Object.entries(opts.query ?? {})) {
34
+ if (v !== undefined && v !== null && v !== "")
35
+ url.searchParams.set(k, String(v));
36
+ }
37
+ const res = await fetch(url, {
38
+ method,
39
+ headers: {
40
+ Authorization: `Bearer ${API_KEY}`,
41
+ // Identifica o canal: a API exige o módulo `mcp` do plano.
42
+ "X-Api-Client": "mcp",
43
+ "Content-Type": "application/json",
44
+ Accept: "application/json",
45
+ },
46
+ body: opts.body != null ? JSON.stringify(opts.body) : undefined,
47
+ });
48
+ const text = await res.text();
49
+ if (!res.ok) {
50
+ throw new Error(`HTTP ${res.status} ${path}: ${text || res.statusText}`);
51
+ }
52
+ const ct = res.headers.get("content-type") ?? "";
53
+ return ct.includes("application/json") && text ? JSON.parse(text) : text;
54
+ }
55
+ /** Embrulha um handler: serializa o resultado e formata erros como isError. */
56
+ function tool(fn) {
57
+ return async (args) => {
58
+ try {
59
+ const result = await fn(args);
60
+ const text = typeof result === "string" ? result : JSON.stringify(result, null, 2);
61
+ return { content: [{ type: "text", text }] };
62
+ }
63
+ catch (err) {
64
+ return {
65
+ content: [
66
+ { type: "text", text: err instanceof Error ? err.message : String(err) },
67
+ ],
68
+ isError: true,
69
+ };
70
+ }
71
+ };
72
+ }
73
+ // A versão vem do package.json (que o npm sempre empacota, um nível acima de
74
+ // dist/ e de src/): fixa no código, ela ficou em "1.0.0" até a 1.4.0.
75
+ const { version } = createRequire(import.meta.url)("../package.json");
76
+ const server = new McpServer({ name: "conflaro", version });
77
+ // ── Empresa ────────────────────────────────────────────────────────────────
78
+ server.tool("get_company", "Mostra os dados da empresa dona da chave de API configurada (razão social, CNPJ, IE/IM, regime tributário, endereço).", {}, tool(() => api("GET", "/v1/company")));
79
+ // ── Documentos fiscais ─────────────────────────────────────────────────────
80
+ server.tool("list_fiscal_documents", "Lista os documentos fiscais (NF-e/NFC-e/NFS-e) da empresa, com paginação e filtros por status e tipo.", {
81
+ page: z.number().int().positive().optional().describe("Página (default 1)"),
82
+ pageSize: z.number().int().positive().max(100).optional().describe("Itens por página (default 20, máx 100)"),
83
+ status: z.string().optional().describe("Filtra por status (ex.: AUTHORIZED, DRAFT, REJECTED)"),
84
+ type: z.string().optional().describe("Filtra por tipo (NFE, NFCE, NFSE)"),
85
+ }, tool((a) => api("GET", "/v1/fiscal-documents", { query: a })));
86
+ server.tool("get_fiscal_document", "Detalha um documento fiscal pelo id (status, número, chave de acesso, itens, etc.).", { id: z.string().describe("ID do documento fiscal") }, tool((a) => api("GET", `/v1/fiscal-documents/${a.id}`)));
87
+ /**
88
+ * 🔴 Estes shapes são um RECORTE da API pública, não um espelho dela.
89
+ *
90
+ * O schema de uma tool de MCP viaja para o modelo em TODA conversa, então
91
+ * enumerar os 39 campos de documento e os 57 de item que a API aceita custaria
92
+ * contexto permanente em troca de campos que quase ninguém usa. Por outro lado,
93
+ * campo que não está no schema é campo que o modelo não sabe que existe.
94
+ *
95
+ * O corte: enumerado com descrição o que se usa para emitir uma nota de
96
+ * verdade; o resto entra por `.passthrough()` (nos objetos) e por `extra` (no
97
+ * topo, onde o SDK monta o `z.object` e não dá para abrir). Quem valida é a
98
+ * API — um 422 dela volta como texto de erro com o motivo. É o mesmo idioma do
99
+ * `productNfeConfigShape` do servidor HTTP.
100
+ *
101
+ * 🔴 Este arquivo é a CÓPIA publicada no npm: `conflaro-mcp` só pode
102
+ * depender de `zod` e do SDK, então os enums que o servidor HTTP lê do
103
+ * Prisma (forma de pagamento, bandeira) aqui são `string` com os valores na
104
+ * descrição — a API recusa o inválido com 422. O que NÃO pode divergir é a
105
+ * lista de CAMPOS: campo ausente é descartado em silêncio, e a nota sai sem
106
+ * ele. Guard: `__tests__/services/mcp-superficie-unica.test.ts`.
107
+ */
108
+ const paymentShape = z
109
+ .object({
110
+ method: z
111
+ .string()
112
+ .describe("Forma de pagamento: MONEY, CHECK, CREDIT_CARD, DEBIT_CARD, STORE_CREDIT, " +
113
+ "FOOD_VOUCHER, MEAL_VOUCHER, GIFT_VOUCHER, FUEL_VOUCHER, BANK_SLIP, " +
114
+ "BANK_DEPOSIT, PIX, WIRE_TRANSFER, LOYALTY, NO_PAYMENT, DEFERRED_PAYMENT, " +
115
+ "OTHERS. Valor fora da tabela é recusado pela API com 422."),
116
+ value: z.number().describe("Valor pago nesta forma (R$)"),
117
+ description: z
118
+ .string()
119
+ .nullish()
120
+ .describe("Descrição da forma. OBRIGATÓRIA quando method=OTHERS."),
121
+ cardFlag: z.string().nullish().describe("Bandeira do cartão (VISA, MASTERCARD, ELO…)"),
122
+ cardCNPJ: z.string().nullish().describe("CNPJ da credenciadora do cartão"),
123
+ cardAuthorization: z.string().nullish().describe("Código de autorização"),
124
+ pixKey: z.string().nullish(),
125
+ pixTxId: z.string().nullish(),
126
+ paidAt: z
127
+ .string()
128
+ .nullish()
129
+ .describe("<dPag> — data em que o pagamento OCORREU (AAAA-MM-DD), que pode não ser a da emissão."),
130
+ })
131
+ .passthrough();
132
+ const duplicateShape = z.object({
133
+ number: z.string().nullish().describe("Número da parcela. Vazio = 001, 002…"),
134
+ dueDate: z.string().describe("Vencimento, AAAA-MM-DD (data pura, sem hora)"),
135
+ value: z.number().positive().describe("Valor da parcela (R$)"),
136
+ });
137
+ const transportShape = z
138
+ .object({
139
+ freightType: z
140
+ .string()
141
+ .nullish()
142
+ .describe("<modFrete>. SENDER=por conta do emitente · RECIPIENT=do destinatário · " +
143
+ "THIRD_PARTY=de terceiros · NO_FREIGHT=sem transporte."),
144
+ freightValue: z
145
+ .number()
146
+ .nonnegative()
147
+ .nullish()
148
+ .describe("Valor do frete da NOTA (R$). É rateado entre os itens e ENTRA NO " +
149
+ "TOTAL da nota — o vNF soma frete, seguro e outras despesas e abate " +
150
+ "o desconto."),
151
+ carrierId: z.string().nullish().describe("ID da transportadora cadastrada"),
152
+ vehiclePlate: z.string().nullish(),
153
+ volumeQuantity: z.number().nullish(),
154
+ volumeSpecies: z.string().nullish(),
155
+ volumeGrossWeight: z.number().nullish(),
156
+ volumeNetWeight: z.number().nullish(),
157
+ })
158
+ .passthrough();
159
+ const placeShape = z
160
+ .object({
161
+ kind: z.enum(["PICKUP", "DELIVERY"]).describe("PICKUP=<retirada> · DELIVERY=<entrega>"),
162
+ street: z.string().describe("Logradouro"),
163
+ number: z.string().describe("Número"),
164
+ neighborhood: z.string().describe("Bairro"),
165
+ cityCode: z.string().nullish().describe("Código IBGE do município"),
166
+ cityName: z.string().nullish(),
167
+ state: z.string().describe("UF"),
168
+ zipCode: z.string().nullish(),
169
+ cnpj: z.string().nullish(),
170
+ cpf: z.string().nullish(),
171
+ })
172
+ .passthrough()
173
+ .describe("🔴 Endereço INCOMPLETO não emite o grupo: logradouro, número, bairro, " +
174
+ "município e UF são obrigatórios dentro dele.");
175
+ const referenceShape = z
176
+ .object({
177
+ kind: z
178
+ .enum(["NFE", "NF", "NFP", "CTE", "ECF"])
179
+ .describe("Tipo do documento referenciado. NFE=por chave · NF=nota em papel " +
180
+ "(modelo 1/1A) · NFP=produtor rural · CTE · ECF=cupom fiscal."),
181
+ accessKey: z.string().nullish().describe("Chave de 44 dígitos (kind NFE/CTE)"),
182
+ })
183
+ .passthrough()
184
+ .describe("UM tipo por item — o leiaute declara um xs:choice.");
185
+ const inlineClientShape = z
186
+ .object({
187
+ name: z.string().describe("Razão social ou nome"),
188
+ document: z.string().nullish().describe("CNPJ ou CPF (só dígitos ou com máscara)"),
189
+ email: z.string().nullish(),
190
+ zipCode: z.string().nullish(),
191
+ street: z.string().nullish(),
192
+ number: z.string().nullish(),
193
+ neighborhood: z.string().nullish(),
194
+ city: z.string().nullish(),
195
+ state: z.string().nullish(),
196
+ })
197
+ .passthrough();
198
+ const itemShape = z.object({
199
+ code: z.string().describe("Código/SKU do item"),
200
+ description: z.string().describe("Descrição do item"),
201
+ quantity: z.number().positive().describe("Quantidade"),
202
+ unitValue: z.number().nonnegative().describe("Valor unitário"),
203
+ ncm: z.string().optional().describe("NCM 8 dígitos (NF-e). Sem valor, herda da empresa."),
204
+ cfop: z.string().optional().describe("CFOP (NF-e/NFC-e)"),
205
+ unit: z.string().optional().describe("Unidade comercial. Default: UN."),
206
+ serviceCode: z.string().optional().describe("Código de serviço municipal (NFS-e)"),
207
+ nationalServiceCode: z.string().optional().describe("Código de serviço nacional (NFS-e)"),
208
+ taxRuleId: z.string().optional().describe("ID de regra tributária a aplicar no item"),
209
+ productId: z
210
+ .string()
211
+ .optional()
212
+ .describe("ID de um produto desta empresa (list_products). Liga o item ao cadastro: a tributação própria do produto e o CEST dele passam a valer, e NCM/CFOP ausentes vêm do produto."),
213
+ cest: z
214
+ .string()
215
+ .optional()
216
+ .describe("CEST do item (7 dígitos). Vazio = o do produto. Exigido quando o ICMS tem ST."),
217
+ nbsCode: z
218
+ .string()
219
+ .nullish()
220
+ .describe("Código NBS (<cNBS>, 9 dígitos). Sem valor, herda do produto e depois da empresa."),
221
+ // ACESSÓRIOS do item. 🔴 Entram no TOTAL da nota: o vNF soma frete, seguro e
222
+ // outras despesas e abate o desconto, e a SEFAZ confere (rejeição 610).
223
+ freightValue: z
224
+ .number()
225
+ .nonnegative()
226
+ .nullish()
227
+ .describe("<vFrete> do PRÓPRIO item (R$). Soma-se ao rateio do frete da nota. Entra no total e na base do ICMS/ST."),
228
+ insuranceValue: z
229
+ .number()
230
+ .nonnegative()
231
+ .nullish()
232
+ .describe("<vSeg> do item (R$). Entra no total da nota."),
233
+ otherValue: z
234
+ .number()
235
+ .nonnegative()
236
+ .nullish()
237
+ .describe("<vOutro> — outras despesas acessórias do item (R$). Entra no total da nota."),
238
+ discountValue: z
239
+ .number()
240
+ .nonnegative()
241
+ .nullish()
242
+ .describe("<vDesc> do PRÓPRIO item (R$). Soma-se ao rateio do desconto da nota e é abatido do total."),
243
+ // Impostos — opcionais. Ausentes, vêm da hierarquia (produto → regra → template).
244
+ icmsOrigin: z.string().nullish().describe("Origem da mercadoria (0 a 8)"),
245
+ icmsSituation: z.string().nullish().describe("CST/CSOSN do ICMS"),
246
+ icmsRate: z.number().nullish().describe("Alíquota do ICMS (%)"),
247
+ pisSituation: z.string().nullish().describe("CST do PIS"),
248
+ pisRate: z.number().nullish().describe("Alíquota do PIS (%)"),
249
+ cofinsSituation: z.string().nullish().describe("CST da COFINS"),
250
+ cofinsRate: z.number().nullish().describe("Alíquota da COFINS (%)"),
251
+ ipiSituation: z.string().nullish().describe("CST do IPI"),
252
+ ipiRate: z.number().nullish().describe("Alíquota do IPI (%)"),
253
+ // Parâmetros do ICMS no ITEM — a nota prevalece sobre produto e regra.
254
+ icmsRedBc: z.number().nullish().describe("Redução da base do ICMS (%) — CST 20/70"),
255
+ icmsStModBc: z
256
+ .enum(["4", "6"])
257
+ .nullish()
258
+ .describe("Modalidade da base do ICMS-ST (4 = MVA, 6 = valor da operação)"),
259
+ icmsStMva: z.number().nullish().describe("MVA do ICMS-ST (%)"),
260
+ icmsStRedBc: z.number().nullish().describe("Redução da base do ICMS-ST (%)"),
261
+ icmsStRate: z.number().nullish().describe("Alíquota do ICMS-ST no destino (%)"),
262
+ icmsSnCreditRate: z
263
+ .number()
264
+ .nullish()
265
+ .describe("Alíquota de crédito do Simples (pCredSN, %) — CSOSN 101/201"),
266
+ // DESONERAÇÃO e DIFERIMENTO. 🔴 `icmsDesonValue` e `icmsDesonReason` saem
267
+ // JUNTOS no XML, e o motivo aceito DEPENDE DO CST.
268
+ icmsDesonValue: z
269
+ .number()
270
+ .nonnegative()
271
+ .nullish()
272
+ .describe("Valor do ICMS desonerado (vICMSDeson, R$) — CST 20/30/40/41/50/70/90. Omitido, é calculado pela alíquota."),
273
+ icmsDesonReason: z
274
+ .string()
275
+ .nullish()
276
+ .describe("Motivo da desoneração (motDesICMS). 🔴 A tabela muda POR CST: 3/9/10/11/12 no 20; " +
277
+ "6/7/9 no 30; 1/3/4/5/6/7/8/9/10/11/16/90 no 40, 41 e 50; 3/9/12 no 70 e no 90. " +
278
+ "Motivo fora da tabela do CST é recusado com 422."),
279
+ icmsDesonDeduct: z
280
+ .boolean()
281
+ .nullish()
282
+ .describe("indDeduzDeson — quando verdadeiro, o valor desonerado ABATE o total da nota. Ausente = falso."),
283
+ icmsDeferralRate: z
284
+ .number()
285
+ .nullish()
286
+ .describe("Percentual de diferimento (pDif, %) — CST 51. 100 é o caso comum e resulta em " +
287
+ "vICMS 0,00, que é DIFERENTE de isenção: o imposto existe e será recolhido adiante."),
288
+ // <rastro> (B13) e pedido de compra do ITEM (B4).
289
+ batches: z
290
+ .array(z.object({
291
+ batch: z.string().describe("<nLote> — até 20 caracteres"),
292
+ quantity: z.number().positive().describe("<qLote>"),
293
+ producedAt: z.string().describe("<dFab> — AAAA-MM-DD, sem hora"),
294
+ expiresAt: z.string().describe("<dVal> — AAAA-MM-DD"),
295
+ aggregationCode: z.string().nullish().describe("<cAgreg>"),
296
+ }))
297
+ .max(500)
298
+ .nullish()
299
+ .describe("Lotes deste item. Um item pode ter VÁRIOS — a soma dos `quantity` é a " +
300
+ "quantidade do item. 🔴 Tudo ou nada: lote sem algum dos quatro campos " +
301
+ "obrigatórios é descartado na emissão."),
302
+ purchaseOrder: z
303
+ .string()
304
+ .nullish()
305
+ .describe("<xPed> — pedido de compra DESTE item (até 15 caracteres; o excedente é truncado)."),
306
+ purchaseOrderItem: z
307
+ .string()
308
+ .nullish()
309
+ .describe("<nItemPed> — a linha do pedido (até 6 dígitos). Fora do formato é DESCARTADO."),
310
+ }).passthrough();
311
+ server.tool("create_fiscal_document", "Cria um documento fiscal (rascunho). Use emit=true para já enfileirar a emissão. Impostos são resolvidos pela regra tributária quando não informados.", {
312
+ type: z.enum(["NFE", "NFCE", "NFSE"]).describe("Tipo do documento"),
313
+ clientId: z
314
+ .string()
315
+ .optional()
316
+ .describe("ID de um cliente já cadastrado nesta empresa. Informe `clientId` OU `client`."),
317
+ client: inlineClientShape
318
+ .nullish()
319
+ .describe("Cliente inline — cria ou reusa o cliente junto da nota (reusa quando " +
320
+ "o `document` casa). Alternativa ao `clientId`."),
321
+ items: z.array(itemShape).min(1).describe("Itens do documento"),
322
+ operationNature: z.string().optional().describe("Natureza da operação"),
323
+ taxRuleId: z.string().optional().describe("Regra tributária para os itens sem regra própria"),
324
+ templateId: z
325
+ .string()
326
+ .nullish()
327
+ .describe("Template fiscal a aplicar (list via GET /v1/fiscal-templates). " +
328
+ "Preenche só os campos que você NÃO enviou."),
329
+ additionalInfo: z.string().optional(),
330
+ externalId: z.string().optional().describe("ID externo da venda (dedup/rastreio)"),
331
+ issueDateTime: z.string().optional().describe("Data de emissão ISO 8601. Default: agora."),
332
+ emit: z.boolean().optional().describe("Quando true, já dispara a emissão (assíncrona)."),
333
+ // ── Cobrança e total ────────────────────────────────────────────────
334
+ payments: z
335
+ .array(paymentShape)
336
+ .nullish()
337
+ .describe("Formas de pagamento (<pag>). A soma precisa fechar com o total da nota."),
338
+ duplicates: z
339
+ .array(duplicateShape)
340
+ .nullish()
341
+ .describe("Parcelas da cobrança (<cobr>). Presença indica venda a prazo. 🔴 A SOMA " +
342
+ "PRECISA FECHAR EXATAMENTE com o total da nota — que inclui frete, seguro " +
343
+ "e outras despesas e abate o desconto, NÃO é só o total dos itens. " +
344
+ "Diferença de um centavo é recusada com 400. Omita em venda à vista."),
345
+ transport: transportShape
346
+ .nullish()
347
+ .describe("Grupo X (<transp>) da NF-e — transportadora, veículo e volumes; é onde vai " +
348
+ "o valor do frete. Omita em venda sem transporte. NFS-e não tem esse grupo."),
349
+ discountValue: z
350
+ .number()
351
+ .nonnegative()
352
+ .nullish()
353
+ .describe("Desconto INCONDICIONAL da nota. É rateado entre os itens e ABATE A BASE do " +
354
+ "imposto — não é só um abatimento do total. Desconto condicional (por " +
355
+ "antecipação) não entra aqui."),
356
+ buyerPresence: z
357
+ .enum(["0", "1", "2", "3", "4", "5", "9"])
358
+ .nullish()
359
+ .describe("<indPres>. 0=não se aplica · 1=presencial · 2=internet · 3=teleatendimento · " +
360
+ "4=entrega a domicílio · 5=presencial fora do estabelecimento · 9=outros. " +
361
+ "Omita para automático. A NFC-e só aceita 1, 4 e 5."),
362
+ // ── Finalidade e documentos referenciados (B1) ───────────────────────
363
+ fiscalPurpose: z
364
+ .enum(["COMPLEMENTAR", "AJUSTE"])
365
+ .nullish()
366
+ .describe("COMPLEMENTAR = finNFe 2, para corrigir valor ou imposto declarado A MENOS " +
367
+ "(carrega só a DIFERENÇA). AJUSTE = finNFe 3. 🔴 Exclusiva com " +
368
+ "`isDevolution`: a nota tem UMA finalidade. As duas EXIGEM o documento " +
369
+ "referenciado."),
370
+ isDevolution: z
371
+ .boolean()
372
+ .nullish()
373
+ .describe("Nota de DEVOLUÇÃO (finNFe=4). Entrada ou saída sai da faixa do CFOP dos " +
374
+ "itens, não daqui. Com `referencedAccessKey`, o IPI devolvido é lido do XML " +
375
+ "original."),
376
+ referencedAccessKey: z
377
+ .string()
378
+ .nullish()
379
+ .describe("Chave de 44 dígitos da NF-e original ajustada ou devolvida — vira <refNFe>."),
380
+ references: z
381
+ .array(referenceShape)
382
+ .max(999)
383
+ .nullish()
384
+ .describe("Documentos referenciados (<NFref>), até 999. Cobre nota em papel, produtor " +
385
+ "rural, CT-e e cupom fiscal além da NF-e por chave."),
386
+ // ── Entrega/retirada e pedido de compra (B3/B4) ──────────────────────
387
+ places: z
388
+ .array(placeShape)
389
+ .max(2)
390
+ .nullish()
391
+ .describe("Local de RETIRADA e de ENTREGA, quando diferentes do remetente e do " +
392
+ "destinatário — e-commerce com entrega em outro endereço, dropshipping, " +
393
+ "venda à ordem. Um de cada `kind`."),
394
+ purchaseNote: z.string().nullish().describe("<xNEmp> — nota de EMPENHO (exigida por órgão público)."),
395
+ purchaseOrderRef: z
396
+ .string()
397
+ .nullish()
398
+ .describe("<xPed> do grupo <compra> — pedido da NOTA INTEIRA. 🔴 Não é o `purchaseOrder` do ITEM."),
399
+ purchaseContract: z.string().nullish().describe("<xCont> — número do contrato de compra."),
400
+ // ── NFS-e ────────────────────────────────────────────────────────────
401
+ competence: z
402
+ .string()
403
+ .nullish()
404
+ .describe("NFS-e: competência do serviço (<dCompet>), AAAA-MM ou AAAA-MM-DD. Ausente = " +
405
+ "data de emissão. Serve a quem fatura no mês seguinte ao serviço."),
406
+ serviceMunicipalityCode: z
407
+ .string()
408
+ .nullish()
409
+ .describe("NFS-e: código IBGE do município onde o serviço foi PRESTADO, quando difere do " +
410
+ "que a Regra Fiscal resolveria. 🔴 Obra, evento ou serviço na sede do cliente " +
411
+ "ficavam no município da empresa, e o ISS vai para a prefeitura errada."),
412
+ serviceDeductionValue: z
413
+ .number()
414
+ .nonnegative()
415
+ .nullish()
416
+ .describe("NFS-e: dedução da base em VALOR (<vDR>). 🔴 VENCE o percentual da Regra " +
417
+ "Fiscal — agência de viagem e construção civil deduzem repasse e material, " +
418
+ "que mudam a cada nota."),
419
+ serviceFinalUse: z
420
+ .boolean()
421
+ .nullish()
422
+ .describe("NFS-e: <indFinal> — uso ou consumo PESSOAL de sócio ou administrador (art. 57 " +
423
+ "da LC 214/2025). 🔴 NÃO é o consumidor final da NF-e e não se deriva do " +
424
+ "cliente. Ausente = não."),
425
+ issExigibility: z
426
+ .enum(["1", "2", "3", "4", "5", "6", "7"])
427
+ .nullish()
428
+ .describe("NFS-e: exigibilidade do ISS. 1=exigível · 2=não incidência · 3=isenção · " +
429
+ "4=exportação · 5=imunidade · 6=suspensa por decisão judicial · 7=suspensa " +
430
+ "por processo administrativo. 6 e 7 exigem `issSuspensionProcess`."),
431
+ issSuspensionProcess: z
432
+ .string()
433
+ .nullish()
434
+ .describe("Número do processo da suspensão. Obrigatório com issExigibility 6 ou 7."),
435
+ construction: z
436
+ .object({
437
+ registration: z.string().nullish().describe("Inscrição imobiliária (IPTU). NÃO identifica a obra."),
438
+ code: z.string().nullish().describe("Código CNO/CEI da obra (<cObra>)"),
439
+ cib: z.string().nullish().describe("Código CIB (<cCIB>), 8 caracteres. Só no Padrão Nacional."),
440
+ art: z.string().nullish().describe("Número da ART/RRT"),
441
+ })
442
+ .passthrough()
443
+ .nullish()
444
+ .describe("NFS-e de construção civil (LC 7.02 e 7.05) — é o município DA OBRA que cobra o " +
445
+ "ISS. 🔴 A identificação é um xs:choice: `code`, `cib` ou o endereço, UM " +
446
+ "deles. A inscrição imobiliária sozinha não serve."),
447
+ substitutedDocumentId: z
448
+ .string()
449
+ .nullish()
450
+ .describe("NFS-e: id da nota que esta SUBSTITUI. 🔴 A substituição é UMA operação, não " +
451
+ "'cancela e emite' — quem cancela a antiga é a Sefin, depois de esta ser " +
452
+ "autorizada."),
453
+ substitutionReason: z
454
+ .string()
455
+ .nullish()
456
+ .describe("Justificativa da substituição (TSCodJustSubst): 01 desenquadramento do " +
457
+ "Simples · 02 enquadramento · 03 inclusão retroativa de imunidade · 04 " +
458
+ "exclusão retroativa · 05 rejeição pelo tomador · 99 outros. Vazio = 99. " +
459
+ "🔴 NÃO é a tabela do cancelamento comum (1/2/9)."),
460
+ substitutionNote: z.string().nullish().describe("Texto do motivo da substituição (<xMotivo>)."),
461
+ extra: z
462
+ .record(z.unknown())
463
+ .nullish()
464
+ .describe("Campos da API pública não listados acima, passados como estão. É por aqui que " +
465
+ "entram os grupos raros: ajustes de IBS/CBS no item (rtcCst, rtcCClassTrib, " +
466
+ "rtcAjusteIbs…), ICMS-ST retido anteriormente (icmsStRetBase e família), " +
467
+ "notas de crédito/débito da RTC (rtcCreditType, rtcDebitType), <comExt> da " +
468
+ "NFS-e e o intermediador (intermediaryDocument, intermediarySellerId). " +
469
+ "Referência: GET /v1/openapi.json."),
470
+ }, tool((a) => {
471
+ // `extra` é a válvula de escape do recorte: o schema do SDK é um
472
+ // `z.object` fechado, então campo não declarado seria DESCARTADO em
473
+ // silêncio — o pior desfecho, porque a nota sai sem ele e nada avisa.
474
+ // O declarado vence o `extra` quando os dois trazem a mesma chave.
475
+ const { extra, ...campos } = a;
476
+ const declarados = Object.fromEntries(Object.entries(campos).filter(([, v]) => v !== undefined));
477
+ return api("POST", "/v1/fiscal-documents", {
478
+ body: { ...(extra ?? {}), ...declarados },
479
+ });
480
+ }));
481
+ server.tool("emit_fiscal_document", "Dispara a emissão (assíncrona) de um documento fiscal já criado. Acompanhe o status com get_fiscal_document.", { id: z.string().describe("ID do documento fiscal") }, tool((a) => api("POST", `/v1/fiscal-documents/${a.id}/emit`)));
482
+ server.tool("cancel_fiscal_document", "Cancela um documento fiscal AUTORIZADO, com justificativa.", {
483
+ id: z.string().describe("ID do documento fiscal"),
484
+ reason: z.string().min(15).describe("Justificativa do cancelamento (mín. 15 caracteres)"),
485
+ }, tool((a) => api("POST", `/v1/fiscal-documents/${a.id}/cancel`, { body: { reason: a.reason } })));
486
+ server.tool("correct_fiscal_document", "Emite uma Carta de Correção (CC-e) para um documento fiscal autorizado.", {
487
+ id: z.string().describe("ID do documento fiscal"),
488
+ text: z.string().min(15).describe("Texto da correção (mín. 15 caracteres)"),
489
+ }, tool((a) => api("POST", `/v1/fiscal-documents/${a.id}/correction`, { body: { text: a.text } })));
490
+ server.tool("get_fiscal_document_xml", "Baixa o XML autorizado de um documento fiscal (retorna o XML como texto).", { id: z.string().describe("ID do documento fiscal") }, tool((a) => api("GET", `/v1/fiscal-documents/${a.id}/xml`)));
491
+ // ── GNRE por operação (docs/plans/gnre.md §8) ──
492
+ server.tool("get_fiscal_document_gnre", "GNRE da nota (guia de recolhimento do DIFAL, FCP e ICMS-ST devidos a OUTRA UF): o que a NF-e autorizada exige (`assessment`, pelo XML), as guias geradas (status, valor, vencimento, `digitableLine` para pagar) e a cota do mês. `noGuideReason` explica quando NÃO há guia devida. Valores em string decimal.", { id: z.string().describe("ID do documento fiscal (NF-e)") }, tool((a) => api("GET", `/v1/fiscal-documents/${encodeURIComponent(String(a.id))}/gnre`)));
493
+ server.tool("generate_fiscal_document_gnre", "Gera (ou regera) as guias GNRE da nota no Portal GNRE — fala com o fisco, consome a cota do plano e cria obrigação de pagamento: confirme com o usuário antes. Idempotente (guia emitida não é refeita). O resultado sai em ≥ 30 s: depois consulte get_fiscal_document_gnre (ou refresh_fiscal_document_gnre). `blocked` = falta dado que só a pessoa resolve; o motivo fica na guia. Exige o módulo GNRE (403 `feature_not_enabled`).", { id: z.string().describe("ID do documento fiscal (NF-e autorizada)") }, tool((a) => api("POST", `/v1/fiscal-documents/${encodeURIComponent(String(a.id))}/gnre`)));
494
+ server.tool("refresh_fiscal_document_gnre", "Consulta AGORA no Portal GNRE o resultado das guias enviadas da nota (emitidas ganham linha digitável; rejeitadas, o motivo). `processing: true` = o portal ainda processa; tente de novo em alguns segundos.", { id: z.string().describe("ID do documento fiscal (NF-e)") }, tool((a) => api("POST", `/v1/fiscal-documents/${encodeURIComponent(String(a.id))}/gnre/refresh`)));
495
+ server.tool("list_gnre_guides", "Lista as guias GNRE da empresa (mais recentes primeiro), com filtro por status, UF favorecida e ambiente (padrão PRODUCTION). Sem o módulo GNRE a resposta é 403 `feature_not_enabled` — não conclua que não há guias.", {
496
+ page: z.number().int().positive().optional(),
497
+ pageSize: z.number().int().positive().max(100).optional(),
498
+ // `string` (não enum): o publicado não lê o Prisma — valor inválido volta 400 da API.
499
+ status: z.string().optional().describe("PENDING | SENT | ISSUED | REJECTED | BLOCKED | QUOTA_EXCEEDED | CANCELED"),
500
+ uf: z.string().optional().describe("UF favorecida (sigla, ex.: SP)"),
501
+ environment: z.string().optional().describe("PRODUCTION (padrão) | HOMOLOGATION | ALL"),
502
+ }, tool((a) => api("GET", "/v1/gnre", { query: a })));
503
+ /**
504
+ * Configuração de NF-e do produto, com a TRIBUTAÇÃO PRÓPRIA. Os campos
505
+ * principais estão descritos para o modelo; `passthrough` deixa passar o resto
506
+ * (unidades, código de barras…) — quem valida é a API, e um 422 dela volta como
507
+ * texto de erro com o motivo.
508
+ */
509
+ const productNfeConfigShape = z
510
+ .object({
511
+ internalCfop: z
512
+ .string()
513
+ .nullish()
514
+ .describe("CFOP dentro do estado (5xxx). Os interestaduais omitidos são sugeridos a partir dele (5102 → 6102 / 6108)."),
515
+ interstateContributor: z.string().nullish().describe("CFOP interestadual p/ contribuinte (6xxx)"),
516
+ interstateNonContributor: z.string().nullish().describe("CFOP interestadual p/ não contribuinte (6xxx)"),
517
+ internationalCfop: z.string().nullish().describe("CFOP de exportação (7xxx)"),
518
+ ncm: z.string().nullish().describe("NCM (8 dígitos)"),
519
+ cest: z.string().nullish().describe("CEST (7 dígitos) — obrigatório com ICMS-ST"),
520
+ taxRuleId: z.string().nullish().describe("Regra Fiscal de NF-e do produto (list_tax_rules)"),
521
+ icmsCst: z
522
+ .string()
523
+ .nullish()
524
+ .describe("CST (Regime Normal) ou CSOSN (Simples/MEI) — precisa ser da tabela do regime da empresa"),
525
+ icmsRate: z.number().nullish().describe("Alíquota do ICMS (%)"),
526
+ icmsRedBc: z.number().nullish().describe("Redução da base (%) — CST 20/70"),
527
+ icmsStModBc: z.enum(["4", "6"]).nullish().describe("Base do ICMS-ST: 4 = MVA, 6 = valor da operação"),
528
+ icmsStMva: z.number().nullish().describe("MVA do ICMS-ST (%) — modalidade 4"),
529
+ icmsStRedBc: z.number().nullish().describe("Redução da base do ICMS-ST (%)"),
530
+ icmsStRate: z.number().nullish().describe("Alíquota do ICMS-ST no destino (%)"),
531
+ icmsSnCreditRate: z.number().nullish().describe("Crédito do Simples (pCredSN, %) — CSOSN 101/201"),
532
+ // Desoneração e diferimento do PRÓPRIO produto — o padrão dele, que o item
533
+ // sobrescreve. Enumerados (e não deixados no passthrough) porque o motivo
534
+ // da desoneração depende do CST, e um valor plausível no CST errado reprova
535
+ // o schema com uma mensagem que não diz qual campo está fora.
536
+ icmsDesonValue: z
537
+ .number()
538
+ .nonnegative()
539
+ .nullish()
540
+ .describe("Valor do ICMS desonerado (vICMSDeson, R$) — CST 20/30/40/41/50/70/90. Omitido, é calculado pela alíquota."),
541
+ icmsDesonReason: z
542
+ .string()
543
+ .nullish()
544
+ .describe("Motivo da desoneração (motDesICMS). 🔴 A tabela muda POR CST: 3/9/10/11/12 no 20; " +
545
+ "6/7/9 no 30; 1/3/4/5/6/7/8/9/10/11/16/90 no 40, 41 e 50; 3/9/12 no 70 e no 90. " +
546
+ "Motivo fora da tabela do CST é recusado com 422."),
547
+ icmsDesonDeduct: z
548
+ .boolean()
549
+ .nullish()
550
+ .describe("indDeduzDeson — quando verdadeiro, o valor desonerado ABATE o total da nota. Ausente = falso."),
551
+ icmsDeferralRate: z
552
+ .number()
553
+ .nullish()
554
+ .describe("Percentual de diferimento (pDif, %) — CST 51. 100 é o caso comum e resulta em " +
555
+ "vICMS 0,00, que é DIFERENTE de isenção: o imposto existe e será recolhido adiante."),
556
+ icmsCBenef: z
557
+ .string()
558
+ .nullish()
559
+ .describe("Código de benefício fiscal da UF (cBenef, ex.: RS052001) — acompanha o icmsCst do " +
560
+ "produto. Sem ele, vários estados rejeitam (930) CST com benefício (20, 40, 41, 51, 90…)."),
561
+ pisCst: z.string().nullish(),
562
+ pisRate: z.number().nullish(),
563
+ cofinsCst: z.string().nullish(),
564
+ cofinsRate: z.number().nullish(),
565
+ ipiCst: z.string().nullish(),
566
+ ipiRate: z.number().nullish(),
567
+ })
568
+ .passthrough()
569
+ .describe("Config. de NF-e do produto. Na nota, cada imposto vem do item → da tributação própria do produto → da regra fiscal → do template. Campo em branco cai na regra.");
570
+ const productNfseConfigShape = z
571
+ .object({
572
+ serviceDescription: z.string().optional().describe("Discriminação do serviço"),
573
+ serviceListItem: z.string().nullish().describe("Item da LC 116 (ex.: 01.05)"),
574
+ municipalServiceCode: z.string().nullish(),
575
+ taxRuleId: z.string().nullish().describe("Regra Fiscal de NFS-e"),
576
+ issRate: z.number().nullish().describe("Alíquota do ISS (%). Vazio = a da empresa."),
577
+ })
578
+ .passthrough()
579
+ .describe("Config. de NFS-e do produto");
580
+ // ── Regras tributárias ─────────────────────────────────────────────────────
581
+ server.tool("list_tax_rules", "Lista as regras tributárias da empresa (use o id em taxRuleId ao criar documentos).", {}, tool(() => api("GET", "/v1/tax-rules")));
582
+ // ── Produtos ───────────────────────────────────────────────────────────────
583
+ server.tool("list_products", "Lista os produtos da empresa, com a tributação própria de cada um. Use o id em items[].productId ao criar documentos.", {
584
+ page: z.number().int().positive().optional(),
585
+ pageSize: z.number().int().positive().max(100).optional(),
586
+ search: z.string().optional().describe("Busca por nome ou código"),
587
+ }, tool((a) => api("GET", "/v1/products", { query: a })));
588
+ server.tool("get_product", "Detalha um produto pelo id (configuração de NF-e com a tributação própria, e de NFS-e).", { id: z.string().describe("ID do produto") }, tool((a) => api("GET", `/v1/products/${encodeURIComponent(String(a.id))}`)));
589
+ server.tool("create_product", "Cadastra um produto, opcionalmente com a tributação própria em nfeConfig. Campo de imposto preenchido no produto vale em toda venda dele; em branco, vale a regra fiscal.", {
590
+ name: z.string().describe("Nome do produto"),
591
+ code: z.string().describe("Código/SKU — único na empresa"),
592
+ salePrice: z.number().positive().describe("Preço de venda"),
593
+ profile: z.enum(["PRODUTOR", "AFILIADO", "COPRODUTOR"]).optional().describe("Default: PRODUTOR"),
594
+ emissionModel: z.enum(["NFE", "NFSE", "SPLIT"]).optional().describe("Default: NFE"),
595
+ nfeConfig: productNfeConfigShape.optional(),
596
+ nfseConfig: productNfseConfigShape.optional(),
597
+ }, tool((a) => api("POST", "/v1/products", { body: a })));
598
+ server.tool("update_product", "Atualiza um produto. Só os campos enviados mudam (null limpa); na tributação, o enviado é mesclado com o gravado — mandar só pisCst não apaga o CSOSN.", {
599
+ id: z.string().describe("ID do produto"),
600
+ name: z.string().optional(),
601
+ code: z.string().optional(),
602
+ salePrice: z.number().positive().optional(),
603
+ profile: z.enum(["PRODUTOR", "AFILIADO", "COPRODUTOR"]).optional(),
604
+ emissionModel: z.enum(["NFE", "NFSE", "SPLIT"]).optional(),
605
+ nfeConfig: productNfeConfigShape.optional(),
606
+ nfseConfig: productNfseConfigShape.optional(),
607
+ }, tool(({ id, ...body }) => api("PATCH", `/v1/products/${encodeURIComponent(String(id))}`, { body })));
608
+ // ── Financeiro (AP/AR) ─────────────────────────────────────────────────────
609
+ //
610
+ // As MESMAS leituras que o chat do sistema executa, aqui pela API pública. A
611
+ // regra vive num lugar só (`finance/titles/`) — se cada caminho calculasse "o
612
+ // que está vencido", o assistente do site e o do ChatGPT dariam números
613
+ // diferentes para a mesma empresa.
614
+ //
615
+ // Quem pode: a chave precisa carregar `finance:read`/`finance:write`
616
+ // LITERALMENTE (não vale "chave sem escopo = tudo") e a empresa precisa ter o
617
+ // módulo. As duas condições são checadas na API — aqui só encaminhamos, e um
618
+ // 403 dela vira texto de erro para o modelo.
619
+ server.tool("list_overdue_receivables", "Contas a receber VENCIDAS, da mais antiga para a mais nova (ordem de cobrança). `total` soma todas as vencidas mesmo quando a lista vem truncada.", { limite: z.number().int().positive().max(100).optional().describe("Máx. de contas listadas. Default: 20.") }, tool((a) => api("GET", "/v1/finance/receivables/overdue", { query: a })));
620
+ server.tool("top_delinquent_clients", "Quem mais deve, agrupado por cliente, com o maior atraso de cada um. Use para priorizar cobrança.", { limite: z.number().int().positive().max(100).optional() }, tool((a) => api("GET", "/v1/finance/receivables/delinquent-clients", { query: a })));
621
+ server.tool("cashflow_projection", "Projeção de caixa por vencimento. NÃO inclui saldo bancário, e o que já venceu vem separado — nunca some `vencidoNaoRecebido` ao `resultadoPrevisto`: isso assumiria que o inadimplente vai pagar.", { dias: z.number().int().positive().max(365).optional().describe("Horizonte em dias. Default: 30.") }, tool((a) => api("GET", "/v1/finance/cashflow-projection", { query: a })));
622
+ server.tool("list_finance_titles", "Lista as PARCELAS de contas a receber ou a pagar. A parcela é a unidade que vence e que recebe baixa. Use `derivedStatus` (não `status`) para saber se atrasou.", {
623
+ kind: z.enum(["RECEIVABLE", "PAYABLE"]).describe("RECEIVABLE = a receber; PAYABLE = a pagar"),
624
+ due: z
625
+ .enum([
626
+ "VENCIDAS",
627
+ "VENCE_HOJE",
628
+ "PROXIMOS_7",
629
+ "EM_ABERTO",
630
+ "TODAS",
631
+ "A_CONFIRMAR",
632
+ "D1A30",
633
+ "D31A60",
634
+ "D61A90",
635
+ "D91MAIS",
636
+ ])
637
+ .optional()
638
+ .describe("Recorte. A_CONFIRMAR = fila sem vencimento informado."),
639
+ search: z.string().optional().describe("Busca por descrição ou contraparte"),
640
+ includeSettled: z.boolean().optional().describe("Inclui as já quitadas"),
641
+ page: z.number().int().positive().optional(),
642
+ pageSize: z.number().int().positive().max(100).optional(),
643
+ }, tool((a) => api("GET", "/v1/finance/titles", { query: a })));
644
+ server.tool("get_finance_summary", "Totais em aberto e aging (faixas de atraso) de um lado. As faixas somam `totalAberto − aConfirmar`, não o total.", { kind: z.enum(["RECEIVABLE", "PAYABLE"]) }, tool((a) => api("GET", "/v1/finance/summary", { query: a })));
645
+ server.tool("get_finance_title", "Ficha completa de um título: parcelas e histórico de baixas, incluindo as ESTORNADAS (`reversedAt` preenchido, e fora de toda soma).", { id: z.string().describe("ID do título") }, tool((a) => api("GET", `/v1/finance/titles/${encodeURIComponent(String(a.id))}`)));
646
+ server.tool("create_finance_title", "Lança uma conta a receber ou a pagar. Conta à vista é 1 parcela. Use `schedule` quando já souber os vencimentos — ele vence `installments`/`totalAmount`.", {
647
+ kind: z.enum(["RECEIVABLE", "PAYABLE"]),
648
+ description: z.string().describe("Descrição da conta"),
649
+ totalAmount: z.string().describe('Valor total em string decimal ("1234.56")'),
650
+ dueDate: z.string().describe("Vencimento da 1ª parcela (YYYY-MM-DD)"),
651
+ counterpartyName: z.string().optional().describe("Cliente (receber) ou fornecedor (pagar)"),
652
+ counterpartyDocument: z.string().optional().describe("CPF/CNPJ da contraparte"),
653
+ installments: z.number().int().positive().max(360).optional(),
654
+ interval: z.enum(["MONTHLY", "WEEKLY"]).optional(),
655
+ categoryId: z.string().optional(),
656
+ clientId: z.string().optional(),
657
+ }, tool((a) => api("POST", "/v1/finance/titles", { body: a })));
658
+ server.tool("settle_finance_installment", "Registra uma baixa na PARCELA (não no título). `amount` é o PRINCIPAL, o que abate a conta — juros e multa vão nos campos próprios e não abatem saldo. Valor menor que o saldo é baixa parcial, caminho normal.", {
659
+ installmentId: z.string().describe("ID da PARCELA (vem de list_finance_titles)"),
660
+ amount: z.string().describe('Principal em string decimal ("500.00")'),
661
+ date: z.string().describe("Data da baixa (YYYY-MM-DD)"),
662
+ interestAmount: z.string().optional().describe("Juros — por fora do principal"),
663
+ fineAmount: z.string().optional().describe("Multa — por fora do principal"),
664
+ discountAmount: z.string().optional(),
665
+ }, tool((a) => api("POST", `/v1/finance/installments/${encodeURIComponent(String(a.installmentId))}/settlements`, {
666
+ body: {
667
+ amount: a.amount,
668
+ date: a.date,
669
+ interestAmount: a.interestAmount,
670
+ fineAmount: a.fineAmount,
671
+ discountAmount: a.discountAmount,
672
+ },
673
+ })));
674
+ // ── Notas recebidas (DF-e) e modo contador ─────────────────────────────────
675
+ //
676
+ // Notas RECEBIDAS são as que os fornecedores emitem contra o CNPJ da empresa
677
+ // (Distribuição DF-e da SEFAZ e NFS-e do Padrão Nacional). As mesmas rotas
678
+ // servem os dois tipos de chave, e é o `companyId` que escolhe:
679
+ // - sem `companyId` → a empresa da chave (chave `ck_`/token OAuth, escopo
680
+ // `received-documents:*`);
681
+ // - com `companyId` → uma empresa da CARTEIRA do escritório (chave do
682
+ // escritório `ok_`, modo contador). A API recusa empresa sem vínculo ativo.
683
+ // As ferramentas de fechamento e carteira exigem a chave do escritório; com a
684
+ // chave de uma empresa a API responde 401 `office_key_required`, dizendo isso.
685
+ const RD_COMPANY = z
686
+ .string()
687
+ .optional()
688
+ .describe("SÓ no modo contador (chave do escritório `ok_`): a empresa da carteira. Omita com a chave de uma empresa.");
689
+ function receivedBase(companyId) {
690
+ return companyId
691
+ ? `/v1/accounting/companies/${encodeURIComponent(String(companyId))}/received-documents`
692
+ : "/v1/received-documents";
693
+ }
694
+ server.tool("list_received_documents", "Lista as notas RECEBIDAS (emitidas contra o CNPJ pelos fornecedores), NSU mais novo primeiro. `pending=true` = só as NF-e que ainda precisam de manifestação. `schemaType` RES_NFE é só o resumo: manifeste CIENCIA para liberar o XML completo.", {
695
+ companyId: RD_COMPANY,
696
+ pending: z.boolean().optional().describe("Só a fila de manifestação"),
697
+ model: z.enum(["NFE", "NFSE"]).optional(),
698
+ manifestation: z
699
+ .enum(["NONE", "CIENCIA", "CONFIRMADA", "DESCONHECIDA", "NAO_REALIZADA"])
700
+ .optional(),
701
+ search: z.string().optional().describe("Trecho da chave, do CNPJ ou do nome do emitente"),
702
+ issuerCnpj: z.string().optional().describe("CNPJ do fornecedor"),
703
+ emittedFrom: z.string().optional().describe("Emitidas a partir de (AAAA-MM-DD, BRT)"),
704
+ emittedTo: z.string().optional().describe("Emitidas até (AAAA-MM-DD, BRT)"),
705
+ page: z.number().int().positive().optional(),
706
+ pageSize: z.number().int().positive().max(100).optional(),
707
+ }, tool((a) => {
708
+ const { companyId, ...query } = a;
709
+ return api("GET", receivedBase(companyId), { query });
710
+ }));
711
+ server.tool("get_received_document", "Detalha uma nota recebida: emitente, valor, situação, manifestação e se a DANFE já pode ser gerada (`pdf.available`, com o motivo quando não).", {
712
+ id: z.string().describe("ID da nota recebida"),
713
+ companyId: RD_COMPANY,
714
+ }, tool((a) => api("GET", `${receivedBase(a.companyId)}/${encodeURIComponent(String(a.id))}`)));
715
+ server.tool("get_received_document_xml", "Baixa o XML da nota recebida (o resumo enquanto não houver Ciência; a nota completa depois).", {
716
+ id: z.string().describe("ID da nota recebida"),
717
+ companyId: RD_COMPANY,
718
+ }, tool((a) => api("GET", `${receivedBase(a.companyId)}/${encodeURIComponent(String(a.id))}/xml`)));
719
+ server.tool("manifest_received_document", "Manifesta a nota recebida perante a SEFAZ, EM NOME DA EMPRESA — é ato jurídico e NÃO TEM DESFAZER. Confirme com o usuário antes de DESCONHECIDA ou NAO_REALIZADA (recusam a nota e cancelam a conta a pagar dela). CIENCIA libera o XML completo. NAO_REALIZADA exige `justification` com 15+ caracteres. Só NF-e.", {
720
+ id: z.string().describe("ID da nota recebida"),
721
+ type: z.enum(["CIENCIA", "CONFIRMADA", "DESCONHECIDA", "NAO_REALIZADA"]),
722
+ justification: z.string().optional().describe("Obrigatória em NAO_REALIZADA (15+ caracteres)"),
723
+ companyId: RD_COMPANY,
724
+ }, tool((a) => api("POST", `${receivedBase(a.companyId)}/${encodeURIComponent(String(a.id))}/manifestation`, { body: { type: a.type, justification: a.justification } })));
725
+ server.tool("sync_received_documents", "Busca agora, na SEFAZ, as notas recebidas novas (a busca já roda sozinha a cada hora). Assíncrono. Depois de uma consulta sem novidades a SEFAZ exige 1h de espera: a resposta 429 `sefaz_wait` traz quando tentar de novo — não repita antes.", {
726
+ companyId: RD_COMPANY,
727
+ }, tool((a) => api("POST", `${receivedBase(a.companyId)}/sync`)));
728
+ server.tool("get_received_documents_distribution", "Estado da busca de notas recebidas: último NSU, último retorno da SEFAZ, se há certificado e quando a próxima sincronização manual é permitida (`nextSyncAllowedAt`).", {
729
+ companyId: RD_COMPANY,
730
+ }, tool((a) => api("GET", `${receivedBase(a.companyId)}/distribution`)));
731
+ server.tool("get_received_documents_export_link", "Download em LOTE: todas as notas recebidas de um MÊS num ZIP (XMLs em notas/ e eventos/). Devolve um LINK (`url`) — entregue-o ao usuário como link clicável para ele baixar no navegador; NÃO tente abrir nem ler o ZIP. O link vale 15 minutos. Sem `companyId`: a empresa da chave. MODO CONTADOR (chave do escritório): `companyId` = uma empresa da carteira; `portfolio: true` (ou `companyIds`) = várias empresas num ZIP só, uma pasta por empresa. `pendingSummaries` > 0 = NF-e que só têm o resumo: manifeste CIENCIA para a nota completa entrar no pacote. Mês sem nada volta 404 `no_documents`.", {
732
+ year: z.number().int().describe("Ano (ex.: 2026)"),
733
+ month: z.number().int().min(1).max(12).describe("Mês, 1–12 (mês de EMISSÃO, horário de Brasília)"),
734
+ companyId: RD_COMPANY,
735
+ portfolio: z
736
+ .boolean()
737
+ .optional()
738
+ .describe("SÓ no modo contador: true = a carteira inteira (até 200 empresas) num ZIP só"),
739
+ companyIds: z
740
+ .array(z.string())
741
+ .optional()
742
+ .describe("SÓ no modo contador: estas empresas da carteira num ZIP só (uma pasta cada)"),
743
+ notes: z.boolean().optional().describe("NF-e completas e NFS-e (padrão: sim)"),
744
+ events: z.boolean().optional().describe("Eventos: cancelamentos, CC-e, manifestações (padrão: sim)"),
745
+ summaries: z
746
+ .boolean()
747
+ .optional()
748
+ .describe("Resumos das NF-e ainda não manifestadas (padrão: não)"),
749
+ }, tool((a) => {
750
+ const body = {
751
+ year: a.year,
752
+ month: a.month,
753
+ notes: a.notes,
754
+ events: a.events,
755
+ summaries: a.summaries,
756
+ };
757
+ if (a.portfolio || (Array.isArray(a.companyIds) && a.companyIds.length > 0)) {
758
+ return api("POST", "/v1/accounting/received-documents/export/link", {
759
+ body: { ...body, companyIds: a.companyIds },
760
+ });
761
+ }
762
+ return api("POST", `${receivedBase(a.companyId)}/export/link`, { body });
763
+ }));
764
+ server.tool("get_accounting_office", "MODO CONTADOR (chave do escritório `ok_`): o escritório, quantas empresas estão na carteira e os escopos da chave.", {}, tool(() => api("GET", "/v1/accounting/office")));
765
+ server.tool("get_simples_estimate", "Estimativa do DAS do Simples Nacional na competência (AAAA-MM; padrão o mês corrente), pelas notas autorizadas em produção. É ESTIMATIVA: com `status: incomplete` o `das` vem null e `pendencies` diz o que falta (receita de meses anteriores ao sistema, anexo dos serviços, folha) — nunca invente o valor. `two_scenarios` = fator R sem folha: o DAS fica entre o Anexo III e o V. Escopo `simples:read`.", {
766
+ companyId: RD_COMPANY,
767
+ competence: z.string().optional().describe("Competência AAAA-MM"),
768
+ }, tool((a) => {
769
+ const { companyId, ...query } = a;
770
+ const path = companyId
771
+ ? `/v1/accounting/companies/${encodeURIComponent(String(companyId))}/simples/estimate`
772
+ : "/v1/simples/estimate";
773
+ return api("GET", path, { query });
774
+ }));
775
+ server.tool("list_portfolio_das_estimates", "MODO CONTADOR: o DAS estimado de cada empresa da carteira na competência (AAAA-MM; padrão o mês corrente). `status: incomplete` = faltam dados naquela empresa (detalhe com get_simples_estimate + companyId); `error` = a conta falhou só nela.", {
776
+ competence: z.string().optional().describe("Competência AAAA-MM"),
777
+ }, tool((a) => api("GET", "/v1/accounting/simples/estimates", { query: a })));
778
+ server.tool("simulate_simples_ibscbs_regime", "Simula, para empresa do Simples Nacional, recolher o IBS/CBS DENTRO do DAS × FORA dele (regime regular, opção semestral a partir de 2027: em setembro para jan–jun e em março para jul–dez). Devolve o imposto da empresa nos dois casos (`inside.das` × `outside.total`), o crédito que os clientes com CNPJ recuperam (`clientsCredit`), a `reading` e as `premises`. É ESTIMATIVA para conversar com o contador, nunca recomendação fiscal. `cbs_not_published` = a CBS do ano não saiu: só mande `cbs` se a PESSOA informar a alíquota a simular — nunca chute, e diga que o resultado usa a premissa dela. Cobre 2027 e 2028. Escopo `simples:read`.", {
779
+ companyId: RD_COMPANY,
780
+ year: z.number().int().optional().describe("2027 ou 2028. Padrão: o ano seguinte"),
781
+ cbs: z.number().optional().describe("CBS a simular (%), SÓ quando informada pela pessoa"),
782
+ }, tool((a) => {
783
+ const { companyId, ...query } = a;
784
+ const path = companyId
785
+ ? `/v1/accounting/companies/${encodeURIComponent(String(companyId))}/simples/regime-ibscbs`
786
+ : "/v1/simples/regime-ibscbs";
787
+ return api("GET", path, { query });
788
+ }));
789
+ server.tool("get_simples_das_forecast", "Previsão do DAS do MÊS CORRENTE se o mês seguir no ritmo dos dias já decorridos, e a RBT12 e a faixa do mês seguinte (`nextMonth.warnings`: muda_de_faixa | sublimite | teto — avise ANTES de acontecer). `fewDays: true` = poucos dias, projeção instável: diga isso. Escopo `simples:read`.", {
790
+ companyId: RD_COMPANY,
791
+ }, tool((a) => {
792
+ const path = a.companyId
793
+ ? `/v1/accounting/companies/${encodeURIComponent(String(a.companyId))}/simples/forecast`
794
+ : "/v1/simples/forecast";
795
+ return api("GET", path);
796
+ }));
797
+ server.tool("get_product_tax_in_price", "Quanto do preço de um produto é imposto (empresa do Simples): alíquota efetiva do DAS do anexo do produto, imposto no preço, margem e o PREÇO MÍNIMO para a margem desejada. Valores em string decimal (\"40.00\"). `minimumPrice: null` = imposto + despesas + margem passam de 100%. Nada é gravado. Escopo `simples:read`.", {
798
+ productId: z.string().describe("Id do produto (list_products)"),
799
+ cost: z.string().describe("Custo unitário, decimal em string"),
800
+ price: z.string().optional().describe("Preço a avaliar. Padrão: o preço de venda cadastrado"),
801
+ expensesRate: z.number().optional().describe("Outras despesas, % do preço (cartão, comissão)"),
802
+ marginRate: z.number().optional().describe("Margem desejada, % do preço"),
803
+ }, tool((a) => api("GET", "/v1/simples/pricing", { query: a })));
804
+ server.tool("list_suspicious_received_documents", "NF-e recebidas (últimos 60 dias, ainda sem decisão) de fornecedor que NUNCA emitiu para a empresa e com valor ou mercadoria fora do padrão — pode ser nota fria contra o CNPJ. Apresente como algo a CONFERIR, nunca como acusação. Se a pessoa disser que a compra não aconteceu, a saída é manifestar Desconhecimento da operação (manifest_received_document, `210220`) — só com a confirmação dela. Escopo `received-documents:read`.", {
805
+ companyId: RD_COMPANY,
806
+ }, tool((a) => {
807
+ const path = a.companyId
808
+ ? `/v1/accounting/companies/${encodeURIComponent(String(a.companyId))}/received-documents/suspicious`
809
+ : "/v1/received-documents/suspicious";
810
+ return api("GET", path);
811
+ }));
812
+ server.tool("list_portfolio_companies", "MODO CONTADOR: as empresas-cliente com vínculo ATIVO com o escritório. O `id` de cada uma é o `companyId` das outras ferramentas.", {
813
+ search: z.string().optional().describe("Trecho do nome ou do CNPJ"),
814
+ page: z.number().int().positive().optional(),
815
+ pageSize: z.number().int().positive().max(100).optional(),
816
+ }, tool((a) => api("GET", "/v1/accounting/companies", { query: a })));
817
+ server.tool("list_closing_eligible_companies", "MODO CONTADOR: empresas da carteira com nota de PRODUÇÃO autorizada ou cancelada no mês (BRT) — as que o fechamento vai processar. Mês é 1–12.", {
818
+ year: z.number().int().describe("Ano (ex.: 2026)"),
819
+ month: z.number().int().min(1).max(12).describe("Mês, 1–12"),
820
+ }, tool((a) => api("GET", "/v1/accounting/closings/eligible", { query: a })));
821
+ server.tool("request_fiscal_closings", "MODO CONTADOR: pede o fechamento fiscal do mês — um ZIP por empresa com XML e/ou DANFE. Assíncrono: acompanhe com list_fiscal_closings. Sem `companyIds` vale para a carteira inteira (até 200). Empresa sem nota no mês, com fechamento em andamento ou fora da carteira volta em `skipped`, com o motivo.", {
822
+ year: z.number().int(),
823
+ month: z.number().int().min(1).max(12).describe("Mês, 1–12"),
824
+ companyIds: z.array(z.string()).optional().describe("Omitido = carteira inteira"),
825
+ includeXml: z.boolean().optional().describe("Default true"),
826
+ includePdf: z.boolean().optional().describe("Default true"),
827
+ }, tool((a) => api("POST", "/v1/accounting/closings", { body: a })));
828
+ server.tool("list_fiscal_closings", "MODO CONTADOR: os fechamentos fiscais da carteira e a situação de cada um (PENDING, PROCESSING, COMPLETED, FAILED).", {
829
+ companyId: z.string().optional(),
830
+ year: z.number().int().optional(),
831
+ month: z.number().int().min(1).max(12).optional().describe("1–12"),
832
+ status: z.enum(["PENDING", "PROCESSING", "COMPLETED", "FAILED"]).optional(),
833
+ page: z.number().int().positive().optional(),
834
+ pageSize: z.number().int().positive().max(100).optional(),
835
+ }, tool((a) => api("GET", "/v1/accounting/closings", { query: a })));
836
+ server.tool("get_fiscal_closing_download", "MODO CONTADOR: link para baixar o ZIP de um fechamento COMPLETED (válido por 1 hora). Antes de terminar, a resposta diz a situação atual.", {
837
+ id: z.string().describe("ID do fechamento (closingId)"),
838
+ }, tool((a) => api("GET", `/v1/accounting/closings/${encodeURIComponent(String(a.id))}/download`)));
839
+ // ── Webhooks ───────────────────────────────────────────────────────────────
840
+ server.tool("list_webhooks", "Lista os endpoints de webhook da empresa.", {}, tool(() => api("GET", "/v1/webhooks")));
841
+ server.tool("create_webhook", "Cria um endpoint de webhook que recebe eventos assinados (document.authorized/rejected/cancelled/inutilized). O segredo de assinatura é retornado apenas uma vez.", {
842
+ url: z.string().url().describe("URL HTTPS que recebe os eventos"),
843
+ events: z.array(z.string()).optional().describe("Eventos assinados. Omitido/vazio = todos."),
844
+ }, tool((a) => api("POST", "/v1/webhooks", { body: a })));
845
+ const transport = new StdioServerTransport();
846
+ await server.connect(transport);
847
+ console.error("[conflaro-mcp] pronto (stdio).");
package/package.json CHANGED
@@ -1,6 +1,35 @@
1
1
  {
2
2
  "name": "conflaro-mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.8.1",
4
+ "description": "Servidor MCP da Conflaro — emissão fiscal (NF-e, NFC-e, NFS-e) por IA",
5
+ "mcpName": "io.conttrole/mcp",
6
+ "type": "module",
7
+ "bin": {
8
+ "conflaro-mcp": "dist/index.js",
9
+ "conttrole-mcp": "dist/index.js"
10
+ },
11
+ "files": [
12
+ "dist"
13
+ ],
14
+ "scripts": {
15
+ "build": "tsc -p tsconfig.json",
16
+ "typecheck": "tsc -p tsconfig.json --noEmit",
17
+ "start": "node dist/index.js",
18
+ "dev": "tsx src/index.ts",
19
+ "prepublishOnly": "npm run build"
20
+ },
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "dependencies": {
25
+ "@modelcontextprotocol/sdk": "^1.12.0",
26
+ "zod": "^3.25.0"
27
+ },
28
+ "devDependencies": {
29
+ "tsx": "^4.19.2",
30
+ "typescript": "^5.7.2"
31
+ },
32
+ "engines": {
33
+ "node": ">=18"
34
+ }
35
+ }