diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
index 7799283..d6df2fb 100644
--- a/.claude-plugin/marketplace.json
+++ b/.claude-plugin/marketplace.json
@@ -1,12 +1,14 @@
{
"name": "fiscalapi",
"description": "Plugins de FiscalAPI para agentes de código.",
- "owner": { "name": "FiscalAPI" },
+ "owner": {
+ "name": "FiscalAPI"
+ },
"plugins": [
{
"name": "fiscalapi",
"source": "./",
- "description": "Skills en español para integrar FiscalAPI (CFDI 4.0) en tu ERP, POS o SaaS."
+ "description": "Skills en español para integrar FiscalAPI (CFDI 4.0) en tu ERP, POS, SaaS o app."
}
]
}
diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json
index f5f1739..576bb88 100644
--- a/.claude-plugin/plugin.json
+++ b/.claude-plugin/plugin.json
@@ -1,10 +1,20 @@
{
"name": "fiscalapi",
- "version": "0.1.0",
- "description": "Skills en español para integrar FiscalAPI (CFDI 4.0) en tu ERP, POS o SaaS: primer timbre en pruebas, complementos, ciclo de vida de la factura y diagnóstico de errores del SAT.",
- "author": { "name": "FiscalAPI" },
+ "version": "0.2.0",
+ "description": "Skills en español para integrar FiscalAPI (CFDI 4.0) en tu ERP, POS, SaaS o app: primer timbre en el ambiente de pruebas, CFDI/Factura con complementos, ciclo de vida del CFDI y diagnóstico de errores de validación, rechazos del PAC y errores del servidor.",
+ "author": {
+ "name": "FiscalAPI"
+ },
"homepage": "https://docs.fiscalapi.com",
"repository": "https://github.com/FiscalAPI/fiscalapi-ai",
"license": "MPL-2.0",
- "keywords": ["fiscalapi", "cfdi", "sat", "facturacion-electronica", "mexico"]
+ "keywords": [
+ "fiscalapi",
+ "cfdi",
+ "cfdi-4.0",
+ "factura",
+ "sat",
+ "facturacion-electronica",
+ "mexico"
+ ]
}
diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json
index f5f1739..576bb88 100644
--- a/.cursor-plugin/plugin.json
+++ b/.cursor-plugin/plugin.json
@@ -1,10 +1,20 @@
{
"name": "fiscalapi",
- "version": "0.1.0",
- "description": "Skills en español para integrar FiscalAPI (CFDI 4.0) en tu ERP, POS o SaaS: primer timbre en pruebas, complementos, ciclo de vida de la factura y diagnóstico de errores del SAT.",
- "author": { "name": "FiscalAPI" },
+ "version": "0.2.0",
+ "description": "Skills en español para integrar FiscalAPI (CFDI 4.0) en tu ERP, POS, SaaS o app: primer timbre en el ambiente de pruebas, CFDI/Factura con complementos, ciclo de vida del CFDI y diagnóstico de errores de validación, rechazos del PAC y errores del servidor.",
+ "author": {
+ "name": "FiscalAPI"
+ },
"homepage": "https://docs.fiscalapi.com",
"repository": "https://github.com/FiscalAPI/fiscalapi-ai",
"license": "MPL-2.0",
- "keywords": ["fiscalapi", "cfdi", "sat", "facturacion-electronica", "mexico"]
+ "keywords": [
+ "fiscalapi",
+ "cfdi",
+ "cfdi-4.0",
+ "factura",
+ "sat",
+ "facturacion-electronica",
+ "mexico"
+ ]
}
diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml
index c8499ae..f0d016d 100644
--- a/.github/workflows/check.yml
+++ b/.github/workflows/check.yml
@@ -1,8 +1,9 @@
name: Check
-# En cada PR y cada lunes: las docs cambian aunque este repo no, y un enlace roto deja a una skill sin su fuente.
on:
pull_request:
+ push:
+ branches: [main]
schedule:
- cron: '0 14 * * 1'
workflow_dispatch:
@@ -11,7 +12,8 @@ permissions:
contents: read
jobs:
- check:
+ plugin:
+ name: Plugin (skills, commands and manifests)
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -22,12 +24,46 @@ jobs:
with:
node-version: 24
- - name: Frontmatter y enlaces de las skills
- run: node scripts/check-skills.mjs
+ - name: Skill frontmatter and documentation links
+ run: node scripts/check-skills.mts
- - name: Manifiestos de Claude Code, Codex y Copilot
+ - name: Claude Code, Codex and Copilot manifests
run: npx -y @anthropic-ai/claude-code@2.1.293 plugin validate . --strict
- # Cursor no lee .claude-plugin: su manifiesto es una copia exacta del de Claude Code.
- - name: Manifiesto de Cursor
+ - name: Cursor manifest matches the Claude Code manifest
run: cmp .claude-plugin/plugin.json .cursor-plugin/plugin.json
+
+ docs-mcp:
+ name: Docs MCP server
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: docs-mcp
+ steps:
+ - name: Checkout
+ uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
+
+ - name: Setup Node
+ uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
+ with:
+ node-version: 24
+ cache: npm
+ cache-dependency-path: docs-mcp/package-lock.json
+
+ - name: Install
+ run: npm ci
+
+ - name: Typecheck
+ run: npm run typecheck
+
+ - name: Build
+ run: npm run build
+
+ - name: Test
+ run: npm test
+
+ - name: Generate data from the public sources
+ run: npm run build:data
+
+ - name: Package contents
+ run: npm pack --dry-run
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..419d48a
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,57 @@
+# Contribuir
+
+Gracias por mejorar fiscalapi-ai. Este documento explica cómo está organizado el repositorio y cómo validar un cambio antes de abrir un pull request.
+
+## Estructura
+
+| Ruta | Contenido |
+| --- | --- |
+| `skills/` | Una carpeta por skill, con su `SKILL.md` y, si hace falta, `references/`. |
+| `commands/` | Comandos del plugin, uno por archivo. |
+| `.claude-plugin/`, `.cursor-plugin/` | Manifiestos del plugin y del marketplace. El de Cursor es una copia exacta del de Claude Code. |
+| `docs-mcp/` | Servidor MCP `@fiscalapi/docs-mcp`. |
+| `scripts/check-skills.mts` | Validación del frontmatter de las skills y de los enlaces a docs.fiscalapi.com. |
+
+## Requisitos
+
+Node.js 22.18 o posterior. Los scripts están escritos en TypeScript y Node.js los ejecuta sin compilarlos.
+
+## Skills y comandos
+
+- `name` es igual al nombre de la carpeta: minúsculas, dígitos y guiones.
+- `description` va en una sola línea, en tercera persona, y dice qué hace la skill, cuándo usarla y cuándo no.
+- `SKILL.md` tiene menos de 500 líneas.
+- Las skills enlazan la documentación en lugar de copiarla. Usa la URL `.md` de la sección (`https://docs.fiscalapi.com/
/.md`).
+- Las API keys siempre se leen de variables de entorno, y los ejemplos usan el ambiente de pruebas.
+
+Valida los cambios con:
+
+```
+node scripts/check-skills.mts
+```
+
+## Servidor MCP
+
+```
+cd docs-mcp
+npm ci
+npm run typecheck
+npm run build
+npm test
+```
+
+`npm run build:data` genera `data/` a partir de la especificación OpenAPI pública y de docs.fiscalapi.com. Las pruebas no usan la red: trabajan con los archivos de `test/fixtures/`. Para actualizar la copia de la documentación que usan las pruebas:
+
+```
+node scripts/snapshot-docs.ts https://docs.fiscalapi.com test/fixtures/docs-corpus.json.gz
+```
+
+Convenciones del código:
+
+- TypeScript estricto, sin `any`, sin aserciones de tipo y sin comentarios. Si un fragmento necesita explicarse, se reescribe con nombres más claros.
+- El código y los mensajes internos van en inglés. Los títulos, las descripciones y los resultados de las herramientas van en español, porque los lee el modelo.
+- Cada herramienta es una clase que implementa `McpTool` en `src/tools/` y recibe sus dependencias por interfaces.
+
+## Pull requests
+
+El workflow `Check` debe pasar: valida las skills, los manifiestos y el servidor MCP (typecheck, build, pruebas y contenido del paquete).
diff --git a/README.md b/README.md
index 8b87812..7162b22 100644
--- a/README.md
+++ b/README.md
@@ -1,15 +1,51 @@
# fiscalapi-ai
-Skills en español para integrar [FiscalAPI](https://docs.fiscalapi.com) (CFDI 4.0) en tu ERP, POS o SaaS desde tu agente de código: Claude Code, Codex, Cursor o GitHub Copilot.
+Skills y comandos en español para integrar [FiscalAPI](https://docs.fiscalapi.com) (CFDI 4.0) en tu ERP, POS, SaaS o app desde tu agente de código: Claude Code, Codex, Cursor o GitHub Copilot.
-Las skills no copian la documentación: te llevan a la sección exacta de [docs.fiscalapi.com](https://docs.fiscalapi.com) y te ayudan a decidir qué endpoint, complemento o catálogo usar.
+Las skills no copian la documentación. Llevan al agente a la sección exacta de [docs.fiscalapi.com](https://docs.fiscalapi.com) y le ayudan a decidir qué operación, complemento o catálogo SAT usar.
+
+## Qué incluye
+
+### Skills
+
+El agente carga cada skill cuando la tarea lo requiere.
+
+| Skill | Úsala para |
+| --- | --- |
+| `fiscalapi-integracion` | Empezar: ambiente de pruebas, API key y tenant, SDK según el lenguaje y modo por valores o por referencias. |
+| `fiscalapi-ingreso-egreso` | CFDI/Factura de ingreso, CFDI/Factura de egreso (nota de crédito) y CFDI/Factura global. |
+| `fiscalapi-pagos` | CFDI/Factura de pago (complemento de recepción de pagos 2.0) de facturas PPD. |
+| `fiscalapi-nomina` | CFDI/Factura de nómina (complemento de nómina 1.2). |
+| `fiscalapi-carta-porte` | CFDI/Factura de carta porte (complemento Carta Porte 3.1, autotransporte). |
+| `fiscalapi-comercio-exterior` | CFDI/Factura de comercio exterior (complemento Comercio Exterior 2.0). |
+| `fiscalapi-ciclo-de-vida` | Cancelación, estatus ante el SAT, XML, PDF, envío por correo y webhooks. |
+| `fiscalapi-diagnostico` | Interpretar errores de validación, rechazos del PAC y errores del servidor. |
+
+### Comandos
+
+| Comando | Qué hace |
+| --- | --- |
+| `primer-timbre` | Timbra tu primera factura de ingreso en el ambiente de pruebas con el SDK de tu proyecto y devuelve su UUID. |
+| `explicar-error` | Explica un error de la API (un código como `CFDI40147` o la respuesta JSON completa) y propone la corrección. |
+| `revisar-integracion` | Revisa una integración existente y reporta los defectos que causan problemas en producción, con la línea y la corrección de cada uno. |
+
+### Servidor MCP de documentación
+
+[`@fiscalapi/docs-mcp`](docs-mcp/README.md) es opcional y complementa a las skills: le da al agente la especificación pública de la API y una búsqueda en la documentación, sin llamar a la API ni requerir API key. Se instala por separado.
+
+## Requisitos
+
+Para timbrar en el ambiente de pruebas necesitas una cuenta, una API key y el tenant. Sigue [Pasos para comenzar](https://docs.fiscalapi.com/quickstart/pasos-para-comenzar.md).
## Instalación
### Claude Code
+En una sesión:
+
```
-/plugin install fiscalapi --marketplace FiscalAPI/fiscalapi-ai
+/plugin marketplace add FiscalAPI/fiscalapi-ai
+/plugin install fiscalapi@fiscalapi
```
O desde la terminal:
@@ -21,21 +57,22 @@ claude plugin install fiscalapi@fiscalapi
### Codex
+Agrega el marketplace y después instala `fiscalapi` desde `/plugins` en una sesión de Codex:
+
```
codex plugin marketplace add FiscalAPI/fiscalapi-ai
-codex plugin add fiscalapi@fiscalapi
```
-### GitHub Copilot
-
-En Copilot CLI:
+### GitHub Copilot CLI
```
copilot plugin marketplace add FiscalAPI/fiscalapi-ai
copilot plugin install fiscalapi@fiscalapi
```
-En VS Code, agrega el marketplace a tu configuración:
+### VS Code
+
+Agrega el marketplace en tu `settings.json` (función experimental de VS Code) e instala `fiscalapi` desde la vista de plugins del chat:
```json
{
@@ -46,12 +83,21 @@ En VS Code, agrega el marketplace a tu configuración:
### Cursor
-Clona el repo en la carpeta de plugins locales y recarga la ventana (**Developer: Reload Window**):
+Clona el repositorio en la carpeta de plugins locales y recarga la ventana con **Developer: Reload Window**:
```
git clone https://github.com/FiscalAPI/fiscalapi-ai ~/.cursor/plugins/local/fiscalapi
```
+## Seguridad
+
+- Las skills leen la API key y el tenant de variables de entorno (`FISCALAPI_API_URL`, `FISCALAPI_API_KEY`, `FISCALAPI_TENANT`). No los escribas en el código ni en archivos versionados.
+- Trabaja en el ambiente de pruebas (`https://test.fiscalapi.com`). Timbrar en producción es un acto fiscal irreversible.
+
+## Soporte
+
+Consulta la [documentación](https://docs.fiscalapi.com) o abre un [issue](https://github.com/FiscalAPI/fiscalapi-ai/issues).
+
## Licencia
[MPL-2.0](LICENSE)
diff --git a/commands/primer-timbre.md b/commands/primer-timbre.md
index a3dff5c..6593446 100644
--- a/commands/primer-timbre.md
+++ b/commands/primer-timbre.md
@@ -3,15 +3,15 @@ description: Timbra tu primera factura de ingreso en el ambiente de pruebas de F
argument-hint: "[lenguaje, si el proyecto todavía no tiene uno]"
---
-Timbra una factura de ingreso de prueba en este proyecto siguiendo la skill `fiscalapi-integracion` (lee su sección de SDK y `references/sdks.md` antes de escribir código).
+Timbra una factura de ingreso de prueba en este proyecto siguiendo la skill `fiscalapi-integracion` (antes de escribir código, lee su sección de SDK y su referencia de SDK).
$ARGUMENTS
-1. **Llaves.** Lee `FISCALAPI_API_KEY` y `FISCALAPI_TENANT` de las variables de entorno, y `FISCALAPI_API_URL` (si no existe, usa `https://test.fiscalapi.com`). Si falta la llave o el tenant, detente y explica cómo obtenerlas con el paso 1 de la skill. Nunca escribas las llaves en el código ni en un archivo, y nunca uses `https://live.fiscalapi.com`.
-2. **Lenguaje y SDK.** Detecta el lenguaje con la tabla de la skill (o usa el que te indiquen arriba; si el proyecto está vacío y no te indican uno, pregunta cuál usar). Instala el SDK oficial con el comando de `references/sdks.md`.
-3. **Certificados de prueba.** Descarga el zip de [Certificados de prueba](https://docs.fiscalapi.com/testing-data/certificados-de-prueba.md) en una carpeta que no se versione (agrégala al `.gitignore`). Del emisor `FUNK671228PH6`, toma el `.cer` y el `.key` de la subcarpeta `CSD_*` (no los de la FIEL) y conviértelos a base64 al ejecutar; la contraseña es `12345678a`.
-4. **Factura.** Arma una factura de ingreso por valores con el cuerpo de ejemplo de [Crear factura ingreso](https://docs.fiscalapi.com/invoices-by-value/crear-factura-ingreso.md): emisor `FUNK671228PH6` (régimen 621), receptor `EKU9003173C9` (régimen 601, uso G01, CP 42501), un concepto con IVA 16 % que conserve todos los campos del concepto del ejemplo (por valores, `itemSku` es obligatorio), y la fecha actual como texto `yyyy-MM-ddTHH:mm:ss`. Usa los nombres del SDK, no los del README si `references/sdks.md` dice que el README falla.
-5. **Timbra una sola vez.** Si `succeeded` es verdadero, imprime el `id` y el `uuid` de `data`. Si no, interpreta la respuesta con la skill `fiscalapi-diagnostico`, corrige y vuelve a intentar. Ante un 5xx o un timeout, no reintentes: sigue la regla de la skill.
+1. **API key y tenant.** Lee `FISCALAPI_API_KEY` y `FISCALAPI_TENANT` de las variables de entorno, y `FISCALAPI_API_URL` (si no existe, usa `https://test.fiscalapi.com`). Si falta la API key o el tenant, detente y explica cómo obtenerlos con la sección 1 de la skill `fiscalapi-integracion`. Nunca escribas la API key ni el tenant en el código ni en un archivo, y nunca uses `https://live.fiscalapi.com`.
+2. **Lenguaje y SDK.** Detecta el lenguaje con la tabla de la skill (o usa el que te indiquen arriba; si el proyecto está vacío y no te indican uno, pregunta cuál usar). Instala el SDK oficial con el comando de la referencia de SDK de la skill `fiscalapi-integracion`.
+3. **Certificados de prueba.** Descarga el zip de [Certificados de prueba](https://docs.fiscalapi.com/testing-data/certificados-de-prueba.md) en una carpeta que no se versione (agrégala al `.gitignore`). Del emisor `FUNK671228PH6`, toma el Certificado CSD (.cer) y la Llave privada CSD (.key) de la subcarpeta `CSD_*` (no el Certificado FIEL (.cer) ni la Llave privada FIEL (.key)) y conviértelos a base64 al ejecutar; la contraseña es `12345678a`.
+4. **Factura.** Arma una factura de ingreso por valores con el cuerpo de ejemplo de [Crear factura ingreso](https://docs.fiscalapi.com/invoices-by-value/crear-factura-ingreso.md): emisor `FUNK671228PH6` (régimen 621), receptor `EKU9003173C9` (régimen 601, uso G01, CP 42501), un concepto con IVA 16 % que conserve todos los campos del concepto del ejemplo (por valores, `itemSku` es obligatorio), y la fecha actual como texto `yyyy-MM-ddTHH:mm:ss`. Usa los nombres de clases y campos de la referencia de SDK de la skill `fiscalapi-integracion`.
+5. **Timbra y revisa la respuesta.** Si `succeeded` es verdadero, imprime el `id` y el `uuid` de `data`. Si es un 400 con fallas de validación o un rechazo del PAC, interprétalo con la skill `fiscalapi-diagnostico`, corrige el dato y vuelve a timbrar: no consume timbre. Ante un 5xx o un timeout, no reintentes: el CFDI pudo quedar timbrado; sigue las reglas clave de la skill `fiscalapi-integracion`.
6. **Comprueba.** Consulta la factura por su `id` con el SDK ([Obtener factura por ID](https://docs.fiscalapi.com/invoices-by-value/obtener-factura-por-id.md)) y confirma que trae el mismo `uuid`.
Responde con el `uuid`, el `id`, los archivos que creaste y el comando para volver a correr el programa.
diff --git a/commands/revisar-integracion.md b/commands/revisar-integracion.md
index 8c1ac72..a747466 100644
--- a/commands/revisar-integracion.md
+++ b/commands/revisar-integracion.md
@@ -9,10 +9,10 @@ $ARGUMENTS
Busca en el código (excluye `node_modules`, `bin`, `obj`, `vendor` y dependencias) estos defectos, en este orden:
-1. **Llaves en el código o en archivos versionados.** Valores con forma de API key de FiscalAPI (`sk_live_`, `sk_test_` o `sk_development_` seguidos de un GUID con guiones bajos), secretos de webhook (`whsec_`), o una API key o tenant escritos como texto en lugar de leerse del entorno o de un gestor de secretos. Revisa también `.env` versionados y archivos de configuración. Una llave `sk_live_` es crítica: indica rotarla en el dashboard.
+1. **API keys en el código o en archivos versionados.** Valores que empiecen con `sk_test_` o `sk_live_`, secretos de webhook (`whsec_`), o una API key o tenant escritos como texto en lugar de leerse del entorno o de un gestor de secretos. Revisa también `.env` versionados y archivos de configuración. Una API key `sk_live_` es crítica: indica rotarla en el dashboard.
2. **Peticiones sin `X-TIME-ZONE`.** Llamadas HTTP directas a FiscalAPI que mandan `X-API-KEY` pero no `X-TIME-ZONE`, o un cliente del SDK sin zona horaria cuando el sistema no trabaja en hora de México.
3. **Reintentos a ciegas tras un 5xx o un timeout al timbrar.** Un ciclo de reintento, una librería de retry o un `catch` que vuelve a llamar `POST /api/v4/invoices` (o `invoices.create`) sin consultar antes si la factura quedó timbrada. Un 5xx o un timeout al timbrar no se reintenta.
-4. **Webhooks sin verificar la firma.** Un endpoint que recibe eventos de FiscalAPI y procesa el cuerpo sin verificar `webhook-signature` con el secreto, o que verifica sobre el JSON ya parseado en lugar del cuerpo crudo.
+4. **Webhooks sin verificar la firma.** Código de la URL del webhook que recibe eventos de FiscalAPI y procesa el cuerpo sin verificar `webhook-signature` con el secreto, o que verifica sobre el JSON ya parseado en lugar del cuerpo crudo.
Además, si los ves, repórtalos: la URL de producción (`live.fiscalapi.com`) en código de pruebas, `succeeded` sin revisar en la respuesta, y una cancelación marcada como definitiva con la sola respuesta del DELETE.
diff --git a/docs-mcp/.gitignore b/docs-mcp/.gitignore
new file mode 100644
index 0000000..e129183
--- /dev/null
+++ b/docs-mcp/.gitignore
@@ -0,0 +1,3 @@
+node_modules/
+dist/
+data/
diff --git a/mcp/LICENSE b/docs-mcp/LICENSE
similarity index 100%
rename from mcp/LICENSE
rename to docs-mcp/LICENSE
diff --git a/docs-mcp/README.md b/docs-mcp/README.md
new file mode 100644
index 0000000..7b8787a
--- /dev/null
+++ b/docs-mcp/README.md
@@ -0,0 +1,93 @@
+# @fiscalapi/docs-mcp
+
+Servidor MCP de solo lectura que pone la especificación pública de la API de [FiscalAPI](https://docs.fiscalapi.com) (CFDI 4.0) y su documentación al alcance de tu agente de código, para que escriba la integración sin adivinar rutas, campos ni reglas fiscales.
+
+El servidor no llama a la API de FiscalAPI y no requiere API key. La especificación y el índice de la documentación vienen dentro del paquete, así que funciona sin conexión.
+
+## Requisitos
+
+Node.js 22 o posterior.
+
+## Instalación
+
+El servidor se ejecuta con `npx -y @fiscalapi/docs-mcp` por stdio. Regístralo en tu cliente con el nombre `fiscalapi-docs`.
+
+### Claude Code
+
+```
+claude mcp add fiscalapi-docs -- npx -y @fiscalapi/docs-mcp
+```
+
+### Codex
+
+```
+codex mcp add fiscalapi-docs -- npx -y @fiscalapi/docs-mcp
+```
+
+O en `~/.codex/config.toml`:
+
+```toml
+[mcp_servers.fiscalapi-docs]
+command = "npx"
+args = ["-y", "@fiscalapi/docs-mcp"]
+```
+
+### GitHub Copilot CLI
+
+```
+copilot mcp add fiscalapi-docs -- npx -y @fiscalapi/docs-mcp
+```
+
+### VS Code
+
+En `.vscode/mcp.json`:
+
+```json
+{
+ "servers": {
+ "fiscalapi-docs": {
+ "type": "stdio",
+ "command": "npx",
+ "args": ["-y", "@fiscalapi/docs-mcp"]
+ }
+ }
+}
+```
+
+### Cursor
+
+En `~/.cursor/mcp.json` (global) o `.cursor/mcp.json` (proyecto):
+
+```json
+{
+ "mcpServers": {
+ "fiscalapi-docs": {
+ "type": "stdio",
+ "command": "npx",
+ "args": ["-y", "@fiscalapi/docs-mcp"]
+ }
+ }
+}
+```
+
+### Otros clientes
+
+Registra un servidor stdio con el comando `npx` y los argumentos `-y @fiscalapi/docs-mcp`.
+
+## Herramientas
+
+| Herramienta | Qué hace |
+| --- | --- |
+| `fiscalapi_list_operations` | Lista las operaciones de la API agrupadas por tag, con `operationId`, método HTTP, ruta y resumen. Acepta `tag` (opcional), por ejemplo `Invoices`. |
+| `fiscalapi_get_operation` | Devuelve en JSON el contrato de una operación por su `operationId` (por ejemplo `createInvoice`): descripción con el enlace a su documentación, parámetros, cuerpo de la petición y respuestas con los esquemas resueltos. Si el `operationId` no existe, sugiere los más parecidos. |
+| `fiscalapi_search_docs` | Busca en las secciones de docs.fiscalapi.com: guías, reglas fiscales del SAT, catálogos SAT, errores de validación y rechazos del PAC por código (por ejemplo `CFDI40147`) y la referencia de cada operación. Devuelve el título, la página, la URL `.md` de la sección y un extracto. Acepta `query` y `limit` (opcional, de 1 a 10; 5 por omisión). |
+
+## Flujo recomendado
+
+1. `fiscalapi_list_operations` para encontrar la operación.
+2. `fiscalapi_get_operation` para leer su contrato antes de escribir el código.
+3. `fiscalapi_search_docs` para saber qué regla aplica, qué significa un error o qué valor de catálogo usar. Para leer una sección completa, el agente descarga su URL `.md`.
+
+## Licencia
+
+[MPL-2.0](https://github.com/FiscalAPI/fiscalapi-ai/blob/main/LICENSE)
diff --git a/mcp/package-lock.json b/docs-mcp/package-lock.json
similarity index 99%
rename from mcp/package-lock.json
rename to docs-mcp/package-lock.json
index c29e417..1c3a7f9 100644
--- a/mcp/package-lock.json
+++ b/docs-mcp/package-lock.json
@@ -1,11 +1,11 @@
{
- "name": "@fiscalapi/mcp",
+ "name": "@fiscalapi/docs-mcp",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "@fiscalapi/mcp",
+ "name": "@fiscalapi/docs-mcp",
"version": "0.1.0",
"license": "MPL-2.0",
"dependencies": {
@@ -16,7 +16,7 @@
"zod": "4.6.7"
},
"bin": {
- "fiscalapi-mcp": "dist/index.js"
+ "fiscalapi-docs-mcp": "dist/index.js"
},
"devDependencies": {
"@modelcontextprotocol/client": "2.3.1",
diff --git a/mcp/package.json b/docs-mcp/package.json
similarity index 58%
rename from mcp/package.json
rename to docs-mcp/package.json
index 9c9d9ab..5ccd16d 100644
--- a/mcp/package.json
+++ b/docs-mcp/package.json
@@ -1,10 +1,10 @@
{
- "name": "@fiscalapi/mcp",
+ "name": "@fiscalapi/docs-mcp",
"version": "0.1.0",
- "description": "Servidor MCP de solo lectura con el catálogo de operaciones de la API de FiscalAPI (CFDI 4.0) y una búsqueda en su documentación",
+ "description": "Servidor MCP de solo lectura con la documentación y la referencia de la API de FiscalAPI (CFDI 4.0) para agentes de código.",
"type": "module",
"bin": {
- "fiscalapi-mcp": "dist/index.js"
+ "fiscalapi-docs-mcp": "dist/index.js"
},
"files": [
"dist",
@@ -12,25 +12,31 @@
"README.md"
],
"scripts": {
- "build:data": "node scripts/build-data.mjs",
- "build": "tsc -p tsconfig.json",
- "test": "vitest run"
+ "build:data": "node scripts/build-data.ts",
+ "clean": "node --eval \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
+ "build": "npm run clean && tsc -p tsconfig.build.json",
+ "typecheck": "tsc -p tsconfig.json",
+ "test": "vitest run",
+ "prepack": "npm run build && npm run build:data"
},
"keywords": [
"fiscalapi",
"mcp",
+ "documentacion",
+ "openapi",
"cfdi",
+ "cfdi-4.0",
"factura",
"sat",
"mexico"
],
- "author": "FISCAL API S DE R.L DE C.V",
+ "author": "FiscalAPI",
"license": "MPL-2.0",
"homepage": "https://docs.fiscalapi.com",
"repository": {
"type": "git",
"url": "git+https://github.com/FiscalAPI/fiscalapi-ai.git",
- "directory": "mcp"
+ "directory": "docs-mcp"
},
"bugs": {
"url": "https://github.com/FiscalAPI/fiscalapi-ai/issues"
diff --git a/docs-mcp/scripts/build-data.ts b/docs-mcp/scripts/build-data.ts
new file mode 100644
index 0000000..3b38696
--- /dev/null
+++ b/docs-mcp/scripts/build-data.ts
@@ -0,0 +1,152 @@
+import { createHash } from 'node:crypto';
+import { existsSync } from 'node:fs';
+import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+import MiniSearch from 'minisearch';
+
+import type { ApiOperation } from '../src/api-operations.ts';
+import { DATA_FILES } from '../src/data-files.ts';
+import { DOCS_INDEX_OPTIONS } from '../src/docs-index-options.ts';
+import type { DocsSection } from '../src/docs-index-options.ts';
+import { DOCS_SITE, downloadMarkdown, fetchLlms, normalizeDocsBaseUrl, parseLlms, toDocsSection } from './docs-sections.ts';
+import { fetchText } from './http.ts';
+import { buildContract, extractOperations, listApiOperations, OpenApiError, parseOpenApi, serializeContract } from './openapi-operations.ts';
+import type { ExtractedOperation, OpenApiDocument } from './openapi-operations.ts';
+import { createLogger, errorMessage, parseScriptArgs, runScript, ScriptError } from './script-runner.ts';
+
+const SCRIPT_NAME = 'build-data';
+const USAGE = 'node scripts/build-data.ts [openapi-url-or-file] [--docs-url ] [--out ]';
+const DEFAULT_OPENAPI_SOURCE = 'https://live.fiscalapi.com/api/openapi/v4.json';
+const DEFAULT_OUT_DIR = fileURLToPath(new URL('../data', import.meta.url));
+const log = createLogger(SCRIPT_NAME);
+
+interface BuildOptions {
+ readonly openApiSource: string;
+ readonly docsBaseUrl: string;
+ readonly outDir: string;
+}
+
+interface OpenApiBuild {
+ readonly source: string;
+ readonly sha256: string;
+ readonly document: OpenApiDocument;
+ readonly operations: readonly ExtractedOperation[];
+ readonly apiOperations: readonly ApiOperation[];
+}
+
+interface DocsBuild {
+ readonly baseUrl: string;
+ readonly sha256: string;
+ readonly sections: number;
+ readonly indexJson: string;
+}
+
+interface SourceManifest {
+ readonly openApi: { readonly source: string; readonly sha256: string; readonly operations: number };
+ readonly docs: { readonly baseUrl: string; readonly sha256: string; readonly sections: number };
+}
+
+function readOptions(): BuildOptions {
+ const { positionals, values } = parseScriptArgs(USAGE, {
+ allowPositionals: true,
+ options: { out: { type: 'string' }, 'docs-url': { type: 'string' } },
+ });
+ return {
+ openApiSource: positionals[0] ?? DEFAULT_OPENAPI_SOURCE,
+ docsBaseUrl: normalizeDocsBaseUrl(values['docs-url'] ?? DOCS_SITE),
+ outDir: path.resolve(values.out ?? DEFAULT_OUT_DIR),
+ };
+}
+
+async function build({ openApiSource, docsBaseUrl, outDir }: BuildOptions): Promise {
+ assertReplaceableDataDir(outDir);
+ const openApi = await buildOpenApi(openApiSource);
+ const docs = await buildDocsIndex(docsBaseUrl);
+ const stagingDir = `${outDir}.staging`;
+ await rm(stagingDir, { recursive: true, force: true });
+ try {
+ await writeDataDir(stagingDir, openApi, docs);
+ await replaceDirectory(outDir, stagingDir);
+ } finally {
+ await rm(stagingDir, { recursive: true, force: true });
+ }
+ log(`${openApi.apiOperations.length} operations and ${docs.sections} docs sections in ${outDir}`);
+ log(`OpenAPI sha256 ${openApi.sha256}; docs sha256 ${docs.sha256}`);
+}
+
+function assertReplaceableDataDir(outDir: string): void {
+ if (existsSync(outDir) && !existsSync(path.join(outDir, DATA_FILES.source))) {
+ throw new ScriptError(`refusing to replace ${outDir}: it exists and has no ${DATA_FILES.source}.`);
+ }
+}
+
+async function buildOpenApi(source: string): Promise {
+ const text = await readOpenApiSource(source);
+ const document = parseOpenApi(text);
+ const operations = extractOperations(document);
+ return { source, sha256: sha256(text), document, operations, apiOperations: listApiOperations(operations) };
+}
+
+async function buildDocsIndex(baseUrl: string): Promise {
+ const llms = await fetchLlms(baseUrl);
+ const downloads = await downloadMarkdown(baseUrl, parseLlms(llms));
+ const index = new MiniSearch(DOCS_INDEX_OPTIONS);
+ index.addAll(downloads.map(({ item, markdown }) => toDocsSection(item, markdown)));
+ return {
+ baseUrl,
+ sha256: sha256(llms, ...downloads.map(({ markdown }) => markdown)),
+ sections: downloads.length,
+ indexJson: JSON.stringify(index),
+ };
+}
+
+async function writeDataDir(dataDir: string, openApi: OpenApiBuild, docs: DocsBuild): Promise {
+ const contractsDir = path.join(dataDir, DATA_FILES.operationContracts);
+ await mkdir(contractsDir, { recursive: true });
+ for (const operation of openApi.operations) {
+ await writeFile(path.join(contractsDir, `${operation.operationId}.json`), serializeContract(buildContract(openApi.document, operation)));
+ }
+ await writeFile(path.join(dataDir, DATA_FILES.operations), JSON.stringify(openApi.apiOperations));
+ await writeFile(path.join(dataDir, DATA_FILES.docsIndex), docs.indexJson);
+ await writeFile(path.join(dataDir, DATA_FILES.source), JSON.stringify(sourceManifest(openApi, docs), null, 2));
+}
+
+function sourceManifest(openApi: OpenApiBuild, docs: DocsBuild): SourceManifest {
+ return {
+ openApi: { source: openApi.source, sha256: openApi.sha256, operations: openApi.apiOperations.length },
+ docs: { baseUrl: docs.baseUrl, sha256: docs.sha256, sections: docs.sections },
+ };
+}
+
+async function replaceDirectory(target: string, replacement: string): Promise {
+ const backup = `${target}.previous`;
+ await rm(backup, { recursive: true, force: true });
+ const hadPrevious = existsSync(target);
+ if (hadPrevious) await rename(target, backup);
+ try {
+ await rename(replacement, target);
+ } catch (error) {
+ if (hadPrevious) await rename(backup, target);
+ throw error;
+ }
+ await rm(backup, { recursive: true, force: true });
+}
+
+async function readOpenApiSource(source: string): Promise {
+ if (/^https?:\/\//i.test(source)) return fetchText(source);
+ try {
+ return await readFile(source, 'utf8');
+ } catch (error) {
+ throw new OpenApiError(`cannot read ${source}: ${errorMessage(error)}`);
+ }
+}
+
+function sha256(...parts: readonly string[]): string {
+ const hash = createHash('sha256');
+ for (const part of parts) hash.update(part);
+ return hash.digest('hex');
+}
+
+await runScript(SCRIPT_NAME, () => build(readOptions()));
diff --git a/docs-mcp/scripts/docs-sections.ts b/docs-mcp/scripts/docs-sections.ts
new file mode 100644
index 0000000..9aad084
--- /dev/null
+++ b/docs-mcp/scripts/docs-sections.ts
@@ -0,0 +1,153 @@
+import type { DocsSection } from '../src/docs-index-options.ts';
+import { fetchText } from './http.ts';
+import { ScriptError } from './script-runner.ts';
+
+export interface LlmsEntry {
+ readonly url: string;
+ readonly page: string;
+}
+
+export interface CleanSection {
+ readonly title: string;
+ readonly endpoint: string;
+ readonly text: string;
+}
+
+export const DOCS_SITE = 'https://docs.fiscalapi.com';
+const DOWNLOAD_CONCURRENCY = 4;
+
+const SITE_MARKDOWN_URL = `${escapeRegExp(DOCS_SITE)}/\\S+?\\.md`;
+const PAGE_LINE = new RegExp(`^- \\[([^\\]]+)\\]\\((${SITE_MARKDOWN_URL})\\)`);
+const SECTION_LINE = new RegExp(`^ {2}- \\[([^\\]]+)\\]\\((${SITE_MARKDOWN_URL})\\)`);
+const ANY_MARKDOWN_LINK = new RegExp(`\\((${SITE_MARKDOWN_URL})\\)`, 'g');
+
+const CODE_BLOCK = /```[\s\S]*?```/g;
+const HEADING = /^#{1,2} (.+)$/m;
+const ANNOTATION = /\{\{([\s\S]*?)\}\}/g;
+const FIRST_ANNOTATION = new RegExp(ANNOTATION.source);
+const ANNOTATION_TAG = /\btag:\s*'([^']*)'/;
+const ANNOTATION_LABEL = /\blabel:\s*'([^']*)'/;
+const TAG_WITH_NAME_ATTRIBUTE = /<[A-Za-z][^>]*?\sname="([^"]*)"[^>]*>/g;
+const ITEM_WITH_VALUE_ATTRIBUTE = /- ]*?\bvalue=(?:'"?([^'"]*)"?'|"([^"]*)")[^>]*>/g;
+const ANY_TAG = /<\/?[A-Za-z][^>]*>/g;
+const LINK = /!?\[([^\]]*)\]\([^)]*\)/g;
+const BACKTICK = /`/g;
+const LINE_INDENT = /^[ \t]+|[ \t]+$/gm;
+const REPEATED_BLANK_LINES = /\n{3,}/g;
+const HTML_DOCUMENT = /^\s*<(!doctype|html)\b/i;
+
+export class DocsError extends ScriptError {}
+
+interface LlmsPage {
+ readonly title: string;
+ readonly url: string;
+ readonly sections: string[];
+}
+
+export function parseLlms(text: string): LlmsEntry[] {
+ const pages = new Map();
+ let currentPage: LlmsPage | undefined;
+ for (const line of text.split(/\r?\n/)) {
+ const [, pageTitle, pageUrl] = PAGE_LINE.exec(line) ?? [];
+ if (pageTitle !== undefined && pageUrl !== undefined) {
+ currentPage = pages.get(pageUrl) ?? { title: pageTitle, url: pageUrl, sections: [] };
+ pages.set(pageUrl, currentPage);
+ continue;
+ }
+ const [, , sectionUrl] = SECTION_LINE.exec(line) ?? [];
+ if (sectionUrl !== undefined) currentPage?.sections.push(sectionUrl);
+ }
+
+ const entries = new Map();
+ for (const page of pages.values()) {
+ const urls = page.sections.length > 0 ? page.sections : [page.url];
+ for (const url of urls) if (!entries.has(url)) entries.set(url, { url, page: page.title });
+ }
+ if (entries.size === 0) throw new DocsError(`llms.txt links no .md page of ${DOCS_SITE}.`);
+ return [...entries.values()];
+}
+
+export function listMarkdownUrls(llms: string): string[] {
+ return [...new Set([...llms.matchAll(ANY_MARKDOWN_LINK)].flatMap(([, url]) => (url === undefined ? [] : [url])))];
+}
+
+export function sitePath(url: string): string {
+ return url.slice(DOCS_SITE.length);
+}
+
+export function cleanSection(markdown: string): CleanSection {
+ const withoutCode = markdown.replace(CODE_BLOCK, '');
+ const heading = HEADING.exec(withoutCode)?.[1] ?? '';
+ const text = withoutCode
+ .replace(HEADING, '')
+ .replace(ANNOTATION, '')
+ .replace(TAG_WITH_NAME_ATTRIBUTE, '$1')
+ .replace(ITEM_WITH_VALUE_ATTRIBUTE, (_, quoted: string | undefined, plain: string | undefined) => `${quoted ?? plain ?? ''} `)
+ .replace(ANY_TAG, '')
+ .replace(LINK, '$1')
+ .replace(BACKTICK, '')
+ .replace(LINE_INDENT, '')
+ .replace(REPEATED_BLANK_LINES, '\n\n')
+ .trim();
+ return { title: heading.replace(ANNOTATION, '').trim(), endpoint: endpointOf(heading), text };
+}
+
+export function toDocsSection({ url, page }: LlmsEntry, markdown: string): DocsSection {
+ const { title, endpoint, text } = cleanSection(markdown);
+ return { id: url, url, page, title: title || page, endpoint, text };
+}
+
+export function fetchLlms(docsBaseUrl: string): Promise {
+ return fetchMarkdown(`${docsBaseUrl}/llms.txt`);
+}
+
+export interface DownloadedMarkdown {
+ readonly item: T;
+ readonly markdown: string;
+}
+
+export async function downloadMarkdown(
+ docsBaseUrl: string,
+ items: readonly T[],
+): Promise[]> {
+ const downloads: DownloadedMarkdown[] = [];
+ const pending = items.entries();
+ const cancellation = new AbortController();
+ async function downloadNext(): Promise {
+ for (const [index, item] of pending) {
+ try {
+ downloads[index] = { item, markdown: await fetchMarkdown(`${docsBaseUrl}${sitePath(item.url)}`, cancellation.signal) };
+ } catch (error) {
+ cancellation.abort(error);
+ throw error;
+ }
+ }
+ }
+ await Promise.all(Array.from({ length: Math.min(DOWNLOAD_CONCURRENCY, items.length) }, downloadNext));
+ return downloads;
+}
+
+export function normalizeDocsBaseUrl(docsBaseUrl: string): string {
+ if (!URL.canParse(docsBaseUrl)) throw new DocsError(`the docs base URL is not valid: ${docsBaseUrl}`);
+ const { protocol } = new URL(docsBaseUrl);
+ if (protocol !== 'http:' && protocol !== 'https:') throw new DocsError(`the docs base URL must be http(s): ${docsBaseUrl}`);
+ return docsBaseUrl.replace(/\/+$/, '');
+}
+
+async function fetchMarkdown(url: string, signal?: AbortSignal): Promise {
+ const body = await fetchText(url, signal);
+ if (HTML_DOCUMENT.test(body)) throw new DocsError(`${url} returned HTML instead of Markdown.`);
+ return body;
+}
+
+function endpointOf(heading: string): string {
+ const annotation = FIRST_ANNOTATION.exec(heading)?.[1];
+ if (annotation === undefined) return '';
+ const method = ANNOTATION_TAG.exec(annotation)?.[1];
+ const path = ANNOTATION_LABEL.exec(annotation)?.[1];
+ return method !== undefined && path !== undefined ? `${method} ${path}` : '';
+}
+
+function escapeRegExp(value: string): string {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
diff --git a/docs-mcp/scripts/http.ts b/docs-mcp/scripts/http.ts
new file mode 100644
index 0000000..d6c42f5
--- /dev/null
+++ b/docs-mcp/scripts/http.ts
@@ -0,0 +1,62 @@
+import { setTimeout as delay } from 'node:timers/promises';
+
+import { errorMessage, ScriptError } from './script-runner.ts';
+
+export const RETRY_POLICY = {
+ maxAttempts: 6,
+ initialBackoffMs: 500,
+ maxBackoffMs: 30_000,
+ requestTimeoutMs: 60_000,
+} as const;
+
+const RETRYABLE_STATUSES: ReadonlySet = new Set([429, 502, 503, 504]);
+
+export class HttpError extends ScriptError {}
+
+type Attempt = { readonly kind: 'response'; readonly response: Response } | { readonly kind: 'networkError'; readonly error: unknown };
+
+export async function fetchText(url: string, signal?: AbortSignal): Promise {
+ for (let attemptNumber = 1; ; attemptNumber++) {
+ signal?.throwIfAborted();
+ const attempt = await request(url, signal);
+ if (attempt.kind === 'response' && attempt.response.status === 200) return attempt.response.text();
+ if (attempt.kind === 'response') await attempt.response.body?.cancel();
+ if (!isRetryable(attempt) || attemptNumber === RETRY_POLICY.maxAttempts) throw failure(url, attempt);
+ await delay(backoffMs(attempt, attemptNumber), undefined, { signal });
+ }
+}
+
+async function request(url: string, signal: AbortSignal | undefined): Promise {
+ const timeout = AbortSignal.timeout(RETRY_POLICY.requestTimeoutMs);
+ try {
+ const response = await fetch(url, { signal: signal === undefined ? timeout : AbortSignal.any([signal, timeout]) });
+ return { kind: 'response', response };
+ } catch (error) {
+ signal?.throwIfAborted();
+ return { kind: 'networkError', error };
+ }
+}
+
+function isRetryable(attempt: Attempt): boolean {
+ return attempt.kind === 'networkError' || RETRYABLE_STATUSES.has(attempt.response.status);
+}
+
+function failure(url: string, attempt: Attempt): HttpError {
+ if (attempt.kind === 'networkError') return new HttpError(`cannot download ${url}: ${errorMessage(attempt.error)}`);
+ return new HttpError(`${url} responded HTTP ${attempt.response.status}; expected 200.`);
+}
+
+function backoffMs(attempt: Attempt, attemptNumber: number): number {
+ const exponentialMs = RETRY_POLICY.initialBackoffMs * 2 ** (attemptNumber - 1) * (1 + Math.random());
+ const requestedMs = attempt.kind === 'response' ? retryAfterMs(attempt.response) : undefined;
+ return Math.min(requestedMs ?? exponentialMs, RETRY_POLICY.maxBackoffMs);
+}
+
+function retryAfterMs(response: Response): number | undefined {
+ const header = response.headers.get('retry-after');
+ if (header === null) return undefined;
+ const seconds = Number(header);
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1_000;
+ const date = Date.parse(header);
+ return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now());
+}
diff --git a/docs-mcp/scripts/openapi-operations.ts b/docs-mcp/scripts/openapi-operations.ts
new file mode 100644
index 0000000..9ebbdda
--- /dev/null
+++ b/docs-mcp/scripts/openapi-operations.ts
@@ -0,0 +1,304 @@
+import type { ApiOperation } from '../src/api-operations.ts';
+import { FILE_SAFE_OPERATION_ID } from '../src/data-files.ts';
+import { ScriptError } from './script-runner.ts';
+
+export type JsonValue = string | number | boolean | null | JsonValue[] | JsonObject;
+
+export interface JsonObject {
+ [key: string]: JsonValue;
+}
+
+export interface OpenApiDocument extends JsonObject {
+ openapi: string;
+ paths: JsonObject;
+}
+
+export interface ExtractedOperation {
+ readonly operationId: string;
+ readonly method: string;
+ readonly path: string;
+ readonly pathItem: JsonObject;
+ readonly operation: JsonObject;
+}
+
+export interface OperationContract extends JsonObject {
+ operationId: string;
+ method: string;
+ path: string;
+ tag: string;
+ summary: string;
+ description: string;
+}
+
+interface TrimOptions {
+ readonly dropExamples: boolean;
+ readonly descriptionDepth: number;
+ readonly refDepth: number;
+}
+
+export const MAX_CONTRACT_CHARS = 40_000;
+
+const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'] as const;
+const PREFERRED_MEDIA_TYPE = 'application/json';
+const DEFAULT_TAG = 'General';
+const FULL: TrimOptions = { dropExamples: false, descriptionDepth: Infinity, refDepth: Infinity };
+
+export class OpenApiError extends ScriptError {}
+
+function isJsonObject(value: JsonValue | undefined): value is JsonObject {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+export function parseOpenApi(text: string): OpenApiDocument {
+ let document: JsonValue;
+ try {
+ document = JSON.parse(text);
+ } catch {
+ throw new OpenApiError('the source is not valid JSON.');
+ }
+ if (!isOpenApiDocument(document)) {
+ throw new OpenApiError('the source is not an OpenAPI 3 document (missing "openapi" or "paths").');
+ }
+ return document;
+}
+
+export function extractOperations(document: OpenApiDocument): ExtractedOperation[] {
+ const operations: ExtractedOperation[] = [];
+ const seen = new Set();
+ for (const [path, pathItem] of Object.entries(document.paths)) {
+ if (!isJsonObject(pathItem)) continue;
+ for (const method of HTTP_METHODS) {
+ const operation = pathItem[method];
+ if (!isJsonObject(operation)) continue;
+ const operationId = operation['operationId'];
+ if (typeof operationId !== 'string' || !FILE_SAFE_OPERATION_ID.test(operationId)) {
+ throw new OpenApiError(`${method.toUpperCase()} ${path} has no valid operationId (${JSON.stringify(operationId)}).`);
+ }
+ const caseInsensitiveId = operationId.toLowerCase();
+ if (seen.has(caseInsensitiveId)) throw new OpenApiError(`duplicate operationId "${operationId}" (ignoring case).`);
+ seen.add(caseInsensitiveId);
+ operations.push({ operationId, method: method.toUpperCase(), path, pathItem, operation });
+ }
+ }
+ if (operations.length === 0) throw new OpenApiError('the document has no operations.');
+ return operations;
+}
+
+export function listApiOperations(operations: readonly ExtractedOperation[]): ApiOperation[] {
+ return operations
+ .map(({ operationId, method, path, operation }) => ({
+ operationId,
+ method,
+ path,
+ summary: stringField(operation, 'summary'),
+ tag: firstTag(operation),
+ }))
+ .sort((a, b) => compareCodeUnits(a.tag, b.tag) || compareCodeUnits(a.operationId, b.operationId));
+}
+
+export function buildContract(
+ document: OpenApiDocument,
+ operation: ExtractedOperation,
+ maxChars: number = MAX_CONTRACT_CHARS,
+): OperationContract {
+ const full = new ContractResolver(document, FULL);
+ const fullContract = full.resolve(operation);
+ if (fits(fullContract, maxChars)) return fullContract;
+
+ for (const options of trimStages(full.maxDepth)) {
+ const contract = new ContractResolver(document, options).resolve(operation);
+ if (fits(contract, maxChars)) return contract;
+ }
+ throw new OpenApiError(`operation "${operation.operationId}" exceeds ${maxChars} characters even when trimmed.`);
+}
+
+export function serializeContract(contract: OperationContract): string {
+ return JSON.stringify(contract);
+}
+
+function* trimStages(maxDepth: number): Generator {
+ yield { dropExamples: true, descriptionDepth: Infinity, refDepth: Infinity };
+ for (let depth = maxDepth; depth >= 1; depth--) yield { dropExamples: true, descriptionDepth: depth, refDepth: Infinity };
+ for (let depth = maxDepth; depth >= 0; depth--) yield { dropExamples: true, descriptionDepth: 1, refDepth: depth };
+}
+
+class ContractResolver {
+ readonly #document: OpenApiDocument;
+ readonly #options: TrimOptions;
+ #maxDepth = 0;
+
+ constructor(document: OpenApiDocument, options: TrimOptions) {
+ this.#document = document;
+ this.#options = options;
+ }
+
+ get maxDepth(): number {
+ return this.#maxDepth;
+ }
+
+ resolve({ operationId, method, path, pathItem, operation }: ExtractedOperation): OperationContract {
+ const contract: OperationContract = {
+ operationId,
+ method,
+ path,
+ tag: firstTag(operation),
+ summary: stringField(operation, 'summary'),
+ description: stringField(operation, 'description'),
+ };
+ if (operation['deprecated'] === true) contract['deprecated'] = true;
+
+ const parameters = [...arrayField(pathItem, 'parameters'), ...arrayField(operation, 'parameters')];
+ if (parameters.length > 0) contract['parameters'] = parameters.map((parameter) => this.#parameter(parameter));
+
+ const requestBody = operation['requestBody'];
+ if (requestBody !== undefined) contract['requestBody'] = this.#withContent(this.#deref(requestBody));
+
+ const responses = operation['responses'];
+ if (isJsonObject(responses)) contract['responses'] = this.#responses(responses);
+
+ const security = operation['security'] ?? this.#document['security'];
+ if (Array.isArray(security)) {
+ contract['security'] = security;
+ contract['securitySchemes'] = this.#securitySchemes(security);
+ }
+ return contract;
+ }
+
+ #parameter(parameter: JsonValue): JsonValue {
+ const resolved = this.#deref(parameter);
+ if (!isJsonObject(resolved)) return resolved;
+ const { schema, ...rest } = this.#withoutExamples(resolved);
+ return schema === undefined ? rest : { ...rest, schema: this.#schema(schema, 0, []) };
+ }
+
+ #responses(responses: JsonObject): JsonObject {
+ return mapValues(responses, (response) => {
+ const resolved = this.#withContent(this.#deref(response));
+ if (!isJsonObject(resolved) || !isJsonObject(resolved['headers'])) return resolved;
+ return { ...resolved, headers: mapValues(resolved['headers'], (header) => this.#parameter(header)) };
+ });
+ }
+
+ #withContent(holder: JsonValue): JsonValue {
+ if (!isJsonObject(holder) || !isJsonObject(holder['content'])) return holder;
+ const content = mapValues(distinctMediaTypes(holder['content']), (media) => {
+ if (!isJsonObject(media)) return media;
+ const { schema, ...rest } = this.#withoutExamples(media);
+ return schema === undefined ? rest : { ...rest, schema: this.#schema(schema, 0, []) };
+ });
+ return { ...holder, content };
+ }
+
+ #schema(schema: JsonValue, depth: number, refStack: readonly string[]): JsonValue {
+ if (!isJsonObject(schema)) return schema;
+ this.#maxDepth = Math.max(this.#maxDepth, depth);
+
+ const { $ref, ...siblings } = schema;
+ if (typeof $ref === 'string') {
+ const name = $ref.slice($ref.lastIndexOf('/') + 1);
+ if (refStack.includes($ref)) return { type: 'object', 'x-recursive': name };
+ if (depth > this.#options.refDepth) return { type: 'object', 'x-truncated': name };
+ const target = this.#pointer($ref);
+ return this.#schema(isJsonObject(target) ? { ...target, ...siblings } : target, depth, [...refStack, $ref]);
+ }
+
+ const result: JsonObject = {};
+ for (const [key, value] of Object.entries(schema)) {
+ if (this.#options.dropExamples && (key === 'example' || key === 'examples')) continue;
+ if (key === 'description' && depth >= this.#options.descriptionDepth) continue;
+ result[key] = this.#schemaKeyword(key, value, depth, refStack);
+ }
+ return result;
+ }
+
+ #schemaKeyword(key: string, value: JsonValue, depth: number, refStack: readonly string[]): JsonValue {
+ switch (key) {
+ case 'properties':
+ case 'patternProperties':
+ return isJsonObject(value) ? mapValues(value, (child) => this.#schema(child, depth + 1, refStack)) : value;
+ case 'items':
+ case 'additionalProperties':
+ case 'not':
+ return this.#schema(value, depth + 1, refStack);
+ case 'allOf':
+ case 'oneOf':
+ case 'anyOf':
+ case 'prefixItems':
+ return Array.isArray(value) ? value.map((child) => this.#schema(child, depth, refStack)) : value;
+ default:
+ return value;
+ }
+ }
+
+ #securitySchemes(security: readonly JsonValue[]): JsonObject {
+ const components = this.#document['components'];
+ const declared = isJsonObject(components) && isJsonObject(components['securitySchemes']) ? components['securitySchemes'] : {};
+ const names = new Set(security.flatMap((requirement) => (isJsonObject(requirement) ? Object.keys(requirement) : [])));
+ return Object.fromEntries(Object.entries(declared).filter(([name]) => names.has(name)));
+ }
+
+ #withoutExamples(object: JsonObject): JsonObject {
+ if (!this.#options.dropExamples) return { ...object };
+ const { example: _example, examples: _examples, ...rest } = object;
+ return rest;
+ }
+
+ #deref(value: JsonValue, seen: ReadonlySet = new Set()): JsonValue {
+ if (!isJsonObject(value)) return value;
+ const ref = value['$ref'];
+ if (typeof ref !== 'string') return value;
+ if (seen.has(ref)) throw new OpenApiError(`circular $ref outside a schema: ${ref}.`);
+ return this.#deref(this.#pointer(ref), new Set([...seen, ref]));
+ }
+
+ #pointer(ref: string): JsonValue {
+ if (!ref.startsWith('#/')) throw new OpenApiError(`only local $ref values are supported: ${ref}.`);
+ let target: JsonValue | undefined = this.#document;
+ for (const segment of ref.slice(2).split('/')) {
+ const key = decodeURIComponent(segment).replaceAll('~1', '/').replaceAll('~0', '~');
+ target = isJsonObject(target) ? target[key] : undefined;
+ }
+ if (target === undefined) throw new OpenApiError(`unresolved $ref: ${ref}.`);
+ return target;
+ }
+}
+
+function isOpenApiDocument(value: JsonValue): value is OpenApiDocument {
+ return isJsonObject(value) && typeof value['openapi'] === 'string' && value['openapi'].startsWith('3.') && isJsonObject(value['paths']);
+}
+
+function distinctMediaTypes(content: JsonObject): JsonObject {
+ const preferred = content[PREFERRED_MEDIA_TYPE];
+ if (preferred === undefined) return content;
+ const preferredJson = JSON.stringify(preferred);
+ return Object.fromEntries(
+ Object.entries(content).filter(([mediaType, media]) => mediaType === PREFERRED_MEDIA_TYPE || JSON.stringify(media) !== preferredJson),
+ );
+}
+
+function stringField(object: JsonObject, key: string): string {
+ const value = object[key];
+ return typeof value === 'string' ? value : '';
+}
+
+function arrayField(object: JsonObject, key: string): JsonValue[] {
+ const value = object[key];
+ return Array.isArray(value) ? value : [];
+}
+
+function firstTag(operation: JsonObject): string {
+ const [tag] = arrayField(operation, 'tags');
+ return typeof tag === 'string' ? tag : DEFAULT_TAG;
+}
+
+function fits(contract: OperationContract, maxChars: number): boolean {
+ return serializeContract(contract).length <= maxChars;
+}
+
+function mapValues(object: JsonObject, transform: (value: JsonValue) => JsonValue): JsonObject {
+ return Object.fromEntries(Object.entries(object).map(([key, value]) => [key, transform(value)]));
+}
+
+function compareCodeUnits(a: string, b: string): number {
+ return a < b ? -1 : a > b ? 1 : 0;
+}
diff --git a/docs-mcp/scripts/script-runner.ts b/docs-mcp/scripts/script-runner.ts
new file mode 100644
index 0000000..97740d9
--- /dev/null
+++ b/docs-mcp/scripts/script-runner.ts
@@ -0,0 +1,34 @@
+import { parseArgs } from 'node:util';
+import type { ParseArgsConfig } from 'node:util';
+
+export class ScriptError extends Error {}
+
+export function errorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error);
+}
+
+export function createLogger(scriptName: string): (message: string) => void {
+ return (message) => console.error(`${scriptName}: ${message}`);
+}
+
+export function parseScriptArgs(usage: string, config: T): ReturnType> {
+ try {
+ return parseArgs(config);
+ } catch (error) {
+ throw new ScriptError(`${errorMessage(error)}\nusage: ${usage}`);
+ }
+}
+
+export async function runScript(scriptName: string, main: () => Promise): Promise {
+ try {
+ await main();
+ } catch (error) {
+ createLogger(scriptName)(failureMessage(error));
+ process.exitCode = 1;
+ }
+}
+
+function failureMessage(error: unknown): string {
+ if (error instanceof ScriptError) return error.message;
+ return error instanceof Error ? (error.stack ?? error.message) : String(error);
+}
diff --git a/docs-mcp/scripts/snapshot-docs.ts b/docs-mcp/scripts/snapshot-docs.ts
new file mode 100644
index 0000000..3257777
--- /dev/null
+++ b/docs-mcp/scripts/snapshot-docs.ts
@@ -0,0 +1,23 @@
+import { writeFile } from 'node:fs/promises';
+import { gzipSync } from 'node:zlib';
+
+import { downloadMarkdown, fetchLlms, listMarkdownUrls, normalizeDocsBaseUrl, sitePath } from './docs-sections.ts';
+import { createLogger, parseScriptArgs, runScript, ScriptError } from './script-runner.ts';
+
+const SCRIPT_NAME = 'snapshot-docs';
+const USAGE = 'node scripts/snapshot-docs.ts ';
+
+async function snapshotDocs(): Promise {
+ const { positionals } = parseScriptArgs(USAGE, { allowPositionals: true, options: {} });
+ const [docsUrlArgument, outFile] = positionals;
+ if (docsUrlArgument === undefined || outFile === undefined) throw new ScriptError(`usage: ${USAGE}`);
+
+ const docsBaseUrl = normalizeDocsBaseUrl(docsUrlArgument);
+ const llms = await fetchLlms(docsBaseUrl);
+ const downloads = await downloadMarkdown(docsBaseUrl, listMarkdownUrls(llms).map((url) => ({ url })));
+ const files = Object.fromEntries(downloads.map(({ item, markdown }) => [sitePath(item.url), markdown]));
+ await writeFile(outFile, gzipSync(JSON.stringify({ llms, files })));
+ createLogger(SCRIPT_NAME)(`${downloads.length} .md files from ${docsBaseUrl} written to ${outFile}`);
+}
+
+await runScript(SCRIPT_NAME, snapshotDocs);
diff --git a/docs-mcp/src/api-operations.ts b/docs-mcp/src/api-operations.ts
new file mode 100644
index 0000000..feead26
--- /dev/null
+++ b/docs-mcp/src/api-operations.ts
@@ -0,0 +1,76 @@
+import { existsSync } from 'node:fs';
+import { readFile } from 'node:fs/promises';
+import { fileURLToPath } from 'node:url';
+
+import * as z from 'zod/v4';
+
+import { DATA_FILES, DataFileError, FILE_SAFE_OPERATION_ID, readDataFile } from './data-files.ts';
+import { suggestOperationIds } from './operation-suggestions.ts';
+
+const apiOperationSchema = z.object({
+ operationId: z.string().regex(FILE_SAFE_OPERATION_ID),
+ method: z.string(),
+ path: z.string(),
+ summary: z.string(),
+ tag: z.string(),
+});
+
+export type ApiOperation = z.infer;
+
+export interface OperationListing {
+ readonly tags: readonly string[];
+ list(tag?: string): readonly ApiOperation[];
+}
+
+export interface OperationContracts {
+ readContract(operationId: string): Promise;
+ suggestSimilarIds(operationId: string): string[];
+}
+
+export class ApiOperations implements OperationListing, OperationContracts {
+ readonly tags: readonly string[];
+ readonly #operations: readonly ApiOperation[];
+ readonly #byId: ReadonlyMap;
+ readonly #contractsDir: URL;
+
+ private constructor(operations: readonly ApiOperation[], contractsDir: URL) {
+ this.#operations = operations;
+ this.#byId = new Map(operations.map((operation) => [operation.operationId, operation]));
+ this.#contractsDir = contractsDir;
+ this.tags = [...new Set(operations.map((operation) => operation.tag))];
+ }
+
+ static load(dataDir: URL): ApiOperations {
+ const operations = readDataFile(dataDir, DATA_FILES.operations, (json) => z.array(apiOperationSchema).parse(JSON.parse(json)));
+ const apiOperations = new ApiOperations(operations, new URL(DATA_FILES.operationContracts, dataDir));
+ apiOperations.#assertContractsExist();
+ return apiOperations;
+ }
+
+ list(tag?: string): readonly ApiOperation[] {
+ if (tag === undefined) return this.#operations;
+ const wantedTag = tag.toLowerCase();
+ return this.#operations.filter((operation) => operation.tag.toLowerCase() === wantedTag);
+ }
+
+ async readContract(operationId: string): Promise {
+ const operation = this.#byId.get(operationId);
+ if (operation === undefined) return undefined;
+ return readFile(this.#contractFile(operation), 'utf8');
+ }
+
+ suggestSimilarIds(operationId: string): string[] {
+ return suggestOperationIds([...this.#byId.keys()], operationId);
+ }
+
+ #contractFile(operation: ApiOperation): URL {
+ return new URL(`${operation.operationId}.json`, this.#contractsDir);
+ }
+
+ #assertContractsExist(): void {
+ const missing = this.#operations.map((operation) => this.#contractFile(operation)).filter((file) => !existsSync(file));
+ if (missing.length > 0) {
+ throw new DataFileError(`Missing operation contracts: ${missing.map((file) => fileURLToPath(file)).join(', ')}.`);
+ }
+ }
+}
diff --git a/docs-mcp/src/data-files.ts b/docs-mcp/src/data-files.ts
new file mode 100644
index 0000000..c348de6
--- /dev/null
+++ b/docs-mcp/src/data-files.ts
@@ -0,0 +1,24 @@
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+
+export const DATA_FILES = {
+ operations: 'operations.json',
+ operationContracts: 'operations/',
+ docsIndex: 'docs-index.json',
+ source: 'source.json',
+} as const;
+
+export const FILE_SAFE_OPERATION_ID = /^[A-Za-z][A-Za-z0-9_]*$/;
+
+export class DataFileError extends Error {}
+
+export function readDataFile(dataDir: URL, name: string, parse: (json: string) => T): T {
+ const file = new URL(name, dataDir);
+ try {
+ return parse(readFileSync(file, 'utf8'));
+ } catch (error) {
+ throw new DataFileError(`Cannot read ${fileURLToPath(file)}. Reinstall the package or regenerate the data with npm run build:data.`, {
+ cause: error,
+ });
+ }
+}
diff --git a/mcp/src/docs-search.ts b/docs-mcp/src/docs-index-options.ts
similarity index 54%
rename from mcp/src/docs-search.ts
rename to docs-mcp/src/docs-index-options.ts
index 1c6e8cd..474c683 100644
--- a/mcp/src/docs-search.ts
+++ b/docs-mcp/src/docs-index-options.ts
@@ -1,11 +1,8 @@
-// Configuración única del índice de la documentación. La importan scripts/build-data.mjs, que arma
-// el índice, y src/docs.ts, que lo carga: si divergieran, las búsquedas no coincidirían con lo indexado.
import { stemmer } from '@orama/stemmers/spanish';
import { stopwords } from '@orama/stopwords/spanish';
import type { Options, SearchOptions } from 'minisearch';
-/** Un documento del índice: una sección de docs.fiscalapi.com, o una página entera si no tiene secciones. */
-export interface DocsDocument {
+export interface DocsSection {
readonly id: string;
readonly url: string;
readonly page: string;
@@ -16,29 +13,30 @@ export interface DocsDocument {
const MIN_FUZZY_TERM_LENGTH = 5;
const FUZZINESS = 0.2;
-// Las marcas diacríticas que NFD separa de su letra: "á" queda como "a" más el acento.
const COMBINING_MARKS = /\p{Mn}/gu;
+const SPACE_OR_PUNCTUATION = /[\n\r\p{Z}\p{P}]+/u;
const STOPWORDS: ReadonlySet = new Set(stopwords.map(withoutAccents));
-/** Minúsculas y sin acentos; descarta las palabras vacías del español y reduce el resto a su raíz. */
+export function tokenize(text: string): string[] {
+ return text.split(SPACE_OR_PUNCTUATION);
+}
+
export function processTerm(term: string): string | null {
const folded = withoutAccents(term.toLowerCase());
return STOPWORDS.has(folded) ? null : stemmer(folded);
}
-// Sin prefijo: con él, "sat" coincidía con cada SatXxx de las tablas de catálogos y la sección de
-// catálogos de Carta Porte encabezaba consultas que no eran de catálogos. El stemmer ya cubre las
-// flexiones. El fuzzy solo aplica a términos de 5 o más letras porque "sat" coincidía con "gat".
const searchOptions: SearchOptions = {
boost: { title: 3, page: 1.5, endpoint: 2 },
prefix: false,
fuzzy: (term) => (term.length >= MIN_FUZZY_TERM_LENGTH ? FUZZINESS : false),
};
-export const DOCS_INDEX_OPTIONS: Options = {
+export const DOCS_INDEX_OPTIONS: Options = {
fields: ['title', 'page', 'endpoint', 'text'],
storeFields: ['title', 'page', 'url', 'text'],
+ tokenize,
processTerm,
searchOptions,
};
diff --git a/docs-mcp/src/docs-index.ts b/docs-mcp/src/docs-index.ts
new file mode 100644
index 0000000..6c128d1
--- /dev/null
+++ b/docs-mcp/src/docs-index.ts
@@ -0,0 +1,56 @@
+import MiniSearch from 'minisearch';
+import * as z from 'zod/v4';
+
+import { DATA_FILES, readDataFile } from './data-files.ts';
+import { DOCS_INDEX_OPTIONS } from './docs-index-options.ts';
+import type { DocsSection } from './docs-index-options.ts';
+import { excerpt } from './excerpt.ts';
+
+export interface DocsSearchResult {
+ readonly title: string;
+ readonly page: string;
+ readonly url: string;
+ readonly excerpt: string;
+}
+
+export interface DocsSearch {
+ search(query: string, limit: number): DocsSearchResult[];
+}
+
+const storedSectionSchema = z.object({
+ title: z.string(),
+ page: z.string(),
+ url: z.string(),
+ text: z.string(),
+});
+
+const serializedIndexSchema = z.object({
+ storedFields: z.record(z.string(), storedSectionSchema),
+});
+
+export class DocsIndex implements DocsSearch {
+ readonly #index: MiniSearch;
+
+ private constructor(index: MiniSearch) {
+ this.#index = index;
+ }
+
+ static load(dataDir: URL): DocsIndex {
+ return new DocsIndex(
+ readDataFile(dataDir, DATA_FILES.docsIndex, (json) => {
+ serializedIndexSchema.parse(JSON.parse(json));
+ return MiniSearch.loadJSON(json, DOCS_INDEX_OPTIONS);
+ }),
+ );
+ }
+
+ search(query: string, limit: number): DocsSearchResult[] {
+ return this.#index
+ .search(query)
+ .slice(0, limit)
+ .map((result) => {
+ const { title, page, url, text } = storedSectionSchema.parse(result);
+ return { title, page, url, excerpt: excerpt(text, result.terms, result.queryTerms) };
+ });
+ }
+}
diff --git a/docs-mcp/src/excerpt.ts b/docs-mcp/src/excerpt.ts
new file mode 100644
index 0000000..6652549
--- /dev/null
+++ b/docs-mcp/src/excerpt.ts
@@ -0,0 +1,70 @@
+import { processTerm, tokenize } from './docs-index-options.ts';
+
+export const MAX_EXCERPT_CHARS = 3_000;
+const MAX_EXCERPT_WORDS = 300;
+const WORDS_BEFORE_MATCH = 40;
+const MAX_CONTEXT_CHARS = MAX_EXCERPT_CHARS / 2;
+const ELLIPSIS = '…';
+const BODY_BUDGET = MAX_EXCERPT_CHARS - 2 * ELLIPSIS.length;
+const MIN_WORD_BOUNDARY_CUT = BODY_BUDGET / 2;
+const WORD = /\S+/g;
+
+interface Word {
+ readonly text: string;
+ readonly start: number;
+ readonly end: number;
+}
+
+type Words = readonly [Word, ...Word[]];
+
+export function excerpt(text: string, matchedTerms: readonly string[], queryTerms: readonly string[]): string {
+ const words = splitWords(text);
+ if (!hasWords(words)) return '';
+ const firstIndex = contextStart(words, firstMatchPreferringExactTerms(words, matchedTerms, queryTerms));
+ const window = words.slice(firstIndex, firstIndex + MAX_EXCERPT_WORDS);
+ if (!hasWords(window)) return '';
+
+ const body = text.slice(window[0].start, lastWord(window).end);
+ const fits = body.length <= BODY_BUDGET;
+ const cutAtStart = firstIndex > 0;
+ const cutAtEnd = !fits || firstIndex + window.length < words.length;
+ return `${cutAtStart ? ELLIPSIS : ''}${fits ? body : truncate(body, BODY_BUDGET)}${cutAtEnd ? ELLIPSIS : ''}`;
+}
+
+function splitWords(text: string): Word[] {
+ return [...text.matchAll(WORD)].map((match) => ({ text: match[0], start: match.index, end: match.index + match[0].length }));
+}
+
+function hasWords(words: readonly Word[]): words is Words {
+ return words.length > 0;
+}
+
+function lastWord(words: Words): Word {
+ return words.reduce((_, word) => word, words[0]);
+}
+
+function firstMatchPreferringExactTerms(words: Words, matchedTerms: readonly string[], queryTerms: readonly string[]): Word {
+ const exactTerms = new Set(matchedTerms.filter((term) => queryTerms.includes(term)));
+ const anyTerms = new Set(matchedTerms);
+ return words.find((word) => containsTerm(word, exactTerms)) ?? words.find((word) => containsTerm(word, anyTerms)) ?? words[0];
+}
+
+function containsTerm(word: Word, terms: ReadonlySet): boolean {
+ return tokenize(word.text).some((token) => {
+ const term = processTerm(token);
+ return term !== null && terms.has(term);
+ });
+}
+
+function contextStart(words: Words, match: Word): number {
+ const matchIndex = words.indexOf(match);
+ const earliest = Math.max(0, matchIndex - WORDS_BEFORE_MATCH);
+ const start = words.findIndex((word, index) => index >= earliest && match.end - word.start <= MAX_CONTEXT_CHARS);
+ return start === -1 || start > matchIndex ? matchIndex : start;
+}
+
+function truncate(value: string, maxChars: number): string {
+ const cut = value.slice(0, maxChars);
+ const lastSpace = cut.search(/\s\S*$/);
+ return (lastSpace >= MIN_WORD_BOUNDARY_CUT ? cut.slice(0, lastSpace) : cut).trimEnd();
+}
diff --git a/docs-mcp/src/index.ts b/docs-mcp/src/index.ts
new file mode 100644
index 0000000..0a4d9a0
--- /dev/null
+++ b/docs-mcp/src/index.ts
@@ -0,0 +1,30 @@
+#!/usr/bin/env node
+import { readFileSync } from 'node:fs';
+import { inspect } from 'node:util';
+
+import { serveStdio } from '@modelcontextprotocol/server/stdio';
+import * as z from 'zod/v4';
+
+import { ApiOperations } from './api-operations.ts';
+import { DocsIndex } from './docs-index.ts';
+import { createServer, SERVER_NAME } from './server.ts';
+
+const packageRoot = new URL('../', import.meta.url);
+const dataDir = new URL('data/', packageRoot);
+
+function readPackageVersion(): string {
+ const packageJson: unknown = JSON.parse(readFileSync(new URL('package.json', packageRoot), 'utf8'));
+ return z.object({ version: z.string() }).parse(packageJson).version;
+}
+
+function logError(error: unknown): void {
+ console.error(`${SERVER_NAME}: ${inspect(error, { depth: 4 })}`);
+}
+
+try {
+ const dependencies = { operations: ApiOperations.load(dataDir), docs: DocsIndex.load(dataDir), version: readPackageVersion() };
+ serveStdio(() => createServer(dependencies), { onerror: logError });
+} catch (error) {
+ logError(error);
+ process.exitCode = 1;
+}
diff --git a/docs-mcp/src/operation-suggestions.ts b/docs-mcp/src/operation-suggestions.ts
new file mode 100644
index 0000000..c838559
--- /dev/null
+++ b/docs-mcp/src/operation-suggestions.ts
@@ -0,0 +1,38 @@
+const MAX_SUGGESTIONS = 5;
+const MIN_SHARED_PREFIX = 3;
+
+export function suggestOperationIds(operationIds: readonly string[], input: string): string[] {
+ const needle = input.trim().toLowerCase();
+ if (needle === '') return [];
+ const substringMatches = idsContaining(operationIds, needle);
+ const suggestions = substringMatches.length > 0 ? substringMatches : idsSharingLongestPrefix(operationIds, needle);
+ return suggestions.slice(0, MAX_SUGGESTIONS);
+}
+
+function idsContaining(operationIds: readonly string[], needle: string): string[] {
+ const startsWithNeedle = (id: string): boolean => id.toLowerCase().startsWith(needle);
+ return [
+ ...operationIds.filter(startsWithNeedle),
+ ...operationIds.filter((id) => !startsWithNeedle(id) && id.toLowerCase().includes(needle)),
+ ];
+}
+
+function idsSharingLongestPrefix(operationIds: readonly string[], needle: string): string[] {
+ const candidates = operationIds.map((id) => ({
+ id,
+ sharedPrefix: sharedPrefixLength(id.toLowerCase(), needle),
+ lengthGap: Math.abs(id.length - needle.length),
+ }));
+ const longest = Math.max(0, ...candidates.map((candidate) => candidate.sharedPrefix));
+ if (longest < MIN_SHARED_PREFIX) return [];
+ return candidates
+ .filter((candidate) => candidate.sharedPrefix === longest)
+ .sort((a, b) => a.lengthGap - b.lengthGap)
+ .map((candidate) => candidate.id);
+}
+
+function sharedPrefixLength(a: string, b: string): number {
+ let length = 0;
+ while (length < a.length && length < b.length && a[length] === b[length]) length++;
+ return length;
+}
diff --git a/docs-mcp/src/server.ts b/docs-mcp/src/server.ts
new file mode 100644
index 0000000..dee9428
--- /dev/null
+++ b/docs-mcp/src/server.ts
@@ -0,0 +1,29 @@
+import { McpServer } from '@modelcontextprotocol/server';
+
+import type { OperationContracts, OperationListing } from './api-operations.ts';
+import type { DocsSearch } from './docs-index.ts';
+import { GetOperationTool } from './tools/get-operation-tool.ts';
+import { ListOperationsTool } from './tools/list-operations-tool.ts';
+import type { McpTool } from './tools/mcp-tool.ts';
+import { SearchDocsTool } from './tools/search-docs-tool.ts';
+
+export const SERVER_NAME = 'fiscalapi-docs';
+
+export interface ServerDependencies {
+ readonly operations: OperationListing & OperationContracts;
+ readonly docs: DocsSearch;
+ readonly version: string;
+}
+
+export function createServer({ operations, docs, version }: ServerDependencies): McpServer {
+ return registerTools(new McpServer({ name: SERVER_NAME, version }), [
+ new ListOperationsTool(operations),
+ new GetOperationTool(operations),
+ new SearchDocsTool(docs),
+ ]);
+}
+
+function registerTools(server: McpServer, tools: readonly McpTool[]): McpServer {
+ for (const tool of tools) tool.register(server);
+ return server;
+}
diff --git a/docs-mcp/src/tools/get-operation-tool.ts b/docs-mcp/src/tools/get-operation-tool.ts
new file mode 100644
index 0000000..eeff782
--- /dev/null
+++ b/docs-mcp/src/tools/get-operation-tool.ts
@@ -0,0 +1,48 @@
+import type { CallToolResult, McpServer } from '@modelcontextprotocol/server';
+import * as z from 'zod/v4';
+
+import type { OperationContracts } from '../api-operations.ts';
+import type { McpTool } from './mcp-tool.ts';
+import { API_SPECIFICATION_NOTICE, MAX_IDENTIFIER_CHARS, READ_ONLY_ANNOTATIONS, TOOL_NAMES } from './tool-conventions.ts';
+import { errorResult, textResult } from './tool-results.ts';
+
+const inputSchema = z.object({
+ operationId: z
+ .string()
+ .min(1)
+ .max(MAX_IDENTIFIER_CHARS)
+ .describe(`operationId exacto, tal como lo devuelve ${TOOL_NAMES.listOperations} (por ejemplo createInvoice).`),
+});
+
+export class GetOperationTool implements McpTool {
+ readonly #contracts: OperationContracts;
+
+ constructor(contracts: OperationContracts) {
+ this.#contracts = contracts;
+ }
+
+ register(server: McpServer): void {
+ server.registerTool(
+ TOOL_NAMES.getOperation,
+ {
+ title: 'Obtener el contrato de una operación de FiscalAPI',
+ description:
+ 'Devuelve en JSON el contrato de una operación de la API de FiscalAPI: método HTTP, ruta, descripción con el enlace a su documentación, parámetros, cuerpo de la petición y respuestas, con los esquemas resueltos. ' +
+ `Un esquema que no cabe se resume como x-truncated y uno recursivo como x-recursive, con el nombre del esquema; para conocer sus campos, búscalo por ese nombre con ${TOOL_NAMES.searchDocs}. ` +
+ `Úsala antes de escribir el código que llama a la operación. Requiere el operationId exacto que devuelve ${TOOL_NAMES.listOperations}. ${API_SPECIFICATION_NOTICE}`,
+ inputSchema,
+ annotations: READ_ONLY_ANNOTATIONS,
+ },
+ ({ operationId }) => this.#getOperation(operationId),
+ );
+ }
+
+ async #getOperation(operationId: string): Promise {
+ const contract = await this.#contracts.readContract(operationId);
+ if (contract !== undefined) return textResult(contract);
+
+ const suggestions = this.#contracts.suggestSimilarIds(operationId);
+ const hint = suggestions.length > 0 ? `¿Quisiste decir ${suggestions.join(', ')}? ` : '';
+ return errorResult(`No existe la operación "${operationId}". ${hint}Usa ${TOOL_NAMES.listOperations} para ver los operationId disponibles.`);
+ }
+}
diff --git a/docs-mcp/src/tools/list-operations-tool.ts b/docs-mcp/src/tools/list-operations-tool.ts
new file mode 100644
index 0000000..7a78aa3
--- /dev/null
+++ b/docs-mcp/src/tools/list-operations-tool.ts
@@ -0,0 +1,54 @@
+import type { CallToolResult, McpServer } from '@modelcontextprotocol/server';
+import * as z from 'zod/v4';
+
+import type { ApiOperation, OperationListing } from '../api-operations.ts';
+import type { McpTool } from './mcp-tool.ts';
+import { API_SPECIFICATION_NOTICE, MAX_IDENTIFIER_CHARS, READ_ONLY_ANNOTATIONS, TOOL_NAMES } from './tool-conventions.ts';
+import { errorResult, textResult } from './tool-results.ts';
+
+export class ListOperationsTool implements McpTool {
+ readonly #operations: OperationListing;
+
+ constructor(operations: OperationListing) {
+ this.#operations = operations;
+ }
+
+ register(server: McpServer): void {
+ server.registerTool(
+ TOOL_NAMES.listOperations,
+ {
+ title: 'Listar las operaciones de la API de FiscalAPI',
+ description:
+ 'Devuelve las operaciones de la API REST de FiscalAPI (CFDI/Factura 4.0 y complementos, cancelación, descarga masiva del SAT, personas, productos, certificados, webhooks y más), agrupadas por tag, con operationId, método HTTP, ruta y resumen. ' +
+ `Úsala para encontrar la operación que necesitas; después llama a ${TOOL_NAMES.getOperation} con su operationId. ${API_SPECIFICATION_NOTICE}`,
+ inputSchema: z.object({
+ tag: z
+ .string()
+ .max(MAX_IDENTIFIER_CHARS)
+ .optional()
+ .describe(`Tag por el que filtrar, sin distinguir mayúsculas. Valores: ${this.#operations.tags.join(', ')}. Omítelo para listar todas.`),
+ }),
+ annotations: READ_ONLY_ANNOTATIONS,
+ },
+ ({ tag }) => this.#listOperations(tag),
+ );
+ }
+
+ #listOperations(tag: string | undefined): CallToolResult {
+ const operations = this.#operations.list(tag);
+ if (operations.length === 0) {
+ return errorResult(`No hay operaciones con el tag "${tag ?? ''}". Tags disponibles: ${this.#operations.tags.join(', ')}.`);
+ }
+ const header = `FiscalAPI: ${operations.length} operaciones. Para ver el contrato de una, llama a ${TOOL_NAMES.getOperation} con su operationId.`;
+ return textResult([header, ...linesGroupedByTag(operations)].join('\n'));
+ }
+}
+
+function linesGroupedByTag(operations: readonly ApiOperation[]): string[] {
+ const groups = Map.groupBy(operations, (operation) => operation.tag);
+ return [...groups].flatMap(([tag, taggedOperations]) => [
+ '',
+ `## ${tag}`,
+ ...taggedOperations.map(({ operationId, method, path, summary }) => `${operationId} ${method} ${path} ${summary}`),
+ ]);
+}
diff --git a/docs-mcp/src/tools/mcp-tool.ts b/docs-mcp/src/tools/mcp-tool.ts
new file mode 100644
index 0000000..3598549
--- /dev/null
+++ b/docs-mcp/src/tools/mcp-tool.ts
@@ -0,0 +1,5 @@
+import type { McpServer } from '@modelcontextprotocol/server';
+
+export interface McpTool {
+ register(server: McpServer): void;
+}
diff --git a/docs-mcp/src/tools/search-docs-tool.ts b/docs-mcp/src/tools/search-docs-tool.ts
new file mode 100644
index 0000000..8236a4c
--- /dev/null
+++ b/docs-mcp/src/tools/search-docs-tool.ts
@@ -0,0 +1,74 @@
+import type { CallToolResult, McpServer } from '@modelcontextprotocol/server';
+import * as z from 'zod/v4';
+
+import type { DocsSearch, DocsSearchResult } from '../docs-index.ts';
+import type { McpTool } from './mcp-tool.ts';
+import { MAX_IDENTIFIER_CHARS, PUBLIC_DOCS_NOTICE, READ_ONLY_ANNOTATIONS, TOOL_NAMES } from './tool-conventions.ts';
+import { textResult } from './tool-results.ts';
+
+export const SEARCH_LIMITS = {
+ maxQueryChars: 200,
+ maxEchoedQueryChars: MAX_IDENTIFIER_CHARS,
+ maxResults: 10,
+ defaultResults: 5,
+} as const;
+
+const inputSchema = z.object({
+ query: z
+ .string()
+ .min(1)
+ .max(SEARCH_LIMITS.maxQueryChars)
+ .describe('Qué buscar, en español o por código (por ejemplo "cancelar factura", "complemento de pago" o "CFDI40147").'),
+ limit: z
+ .number()
+ .int()
+ .min(1)
+ .max(SEARCH_LIMITS.maxResults)
+ .optional()
+ .describe(`Número máximo de resultados, de 1 a ${SEARCH_LIMITS.maxResults}. Por omisión, ${SEARCH_LIMITS.defaultResults}.`),
+});
+
+export class SearchDocsTool implements McpTool {
+ readonly #docs: DocsSearch;
+
+ constructor(docs: DocsSearch) {
+ this.#docs = docs;
+ }
+
+ register(server: McpServer): void {
+ server.registerTool(
+ TOOL_NAMES.searchDocs,
+ {
+ title: 'Buscar en la documentación de FiscalAPI',
+ description:
+ 'Busca en las secciones de docs.fiscalapi.com: guías, reglas fiscales del SAT, catálogos SAT, errores de validación y rechazos del PAC por código (por ejemplo CFDI40147) y la referencia de cada operación. ' +
+ 'Devuelve por resultado el título, la página, la URL .md de la sección y un extracto. ' +
+ `Úsala para saber qué regla aplica, qué significa un error o qué valor de catálogo usar; para leer la sección completa, descarga su URL .md. ${PUBLIC_DOCS_NOTICE}`,
+ inputSchema,
+ annotations: READ_ONLY_ANNOTATIONS,
+ },
+ ({ query, limit }) => this.#searchDocs(query, limit ?? SEARCH_LIMITS.defaultResults),
+ );
+ }
+
+ #searchDocs(query: string, limit: number): CallToolResult {
+ const results = this.#docs.search(query, limit);
+ const echoedQuery = echo(query);
+ if (results.length === 0) {
+ return textResult(
+ `FiscalAPI docs: no hay resultados para "${echoedQuery}". Prueba con otras palabras o usa ${TOOL_NAMES.listOperations} para ver las operaciones de la API.`,
+ );
+ }
+ const count = results.length === 1 ? '1 resultado' : `${results.length} resultados`;
+ const blocks = results.map((result) => `## ${resultHeading(result)}\n${result.url}\n\n${result.excerpt}`);
+ return textResult([`FiscalAPI docs: ${count} para "${echoedQuery}".`, ...blocks].join('\n\n'));
+ }
+}
+
+function echo(query: string): string {
+ return query.length > SEARCH_LIMITS.maxEchoedQueryChars ? `${query.slice(0, SEARCH_LIMITS.maxEchoedQueryChars)}…` : query;
+}
+
+function resultHeading({ title, page }: DocsSearchResult): string {
+ return title === page ? title : `${title} (${page})`;
+}
diff --git a/docs-mcp/src/tools/tool-conventions.ts b/docs-mcp/src/tools/tool-conventions.ts
new file mode 100644
index 0000000..7ad000b
--- /dev/null
+++ b/docs-mcp/src/tools/tool-conventions.ts
@@ -0,0 +1,19 @@
+export const TOOL_NAMES = {
+ listOperations: 'fiscalapi_list_operations',
+ getOperation: 'fiscalapi_get_operation',
+ searchDocs: 'fiscalapi_search_docs',
+} as const;
+
+export const READ_ONLY_ANNOTATIONS = {
+ readOnlyHint: true,
+ destructiveHint: false,
+ idempotentHint: true,
+ openWorldHint: false,
+} as const;
+
+export const API_SPECIFICATION_NOTICE =
+ 'Solo consulta la especificación pública de la API (OpenAPI): no llama a la API y no requiere API key.';
+
+export const PUBLIC_DOCS_NOTICE = 'Solo consulta la documentación pública de FiscalAPI: no llama a la API y no requiere API key.';
+
+export const MAX_IDENTIFIER_CHARS = 100;
diff --git a/docs-mcp/src/tools/tool-results.ts b/docs-mcp/src/tools/tool-results.ts
new file mode 100644
index 0000000..a1ad4c6
--- /dev/null
+++ b/docs-mcp/src/tools/tool-results.ts
@@ -0,0 +1,9 @@
+import type { CallToolResult } from '@modelcontextprotocol/server';
+
+export function textResult(text: string): CallToolResult {
+ return { content: [{ type: 'text', text }] };
+}
+
+export function errorResult(text: string): CallToolResult {
+ return { content: [{ type: 'text', text }], isError: true };
+}
diff --git a/docs-mcp/test/build-data.test.ts b/docs-mcp/test/build-data.test.ts
new file mode 100644
index 0000000..412ba73
--- /dev/null
+++ b/docs-mcp/test/build-data.test.ts
@@ -0,0 +1,394 @@
+import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+
+import { afterAll, describe, expect, it } from 'vitest';
+
+import { DATA_FILES } from '../src/data-files.ts';
+import { cleanSection, DocsError, parseLlms } from '../scripts/docs-sections.ts';
+import {
+ buildContract,
+ extractOperations,
+ listApiOperations,
+ MAX_CONTRACT_CHARS,
+ OpenApiError,
+ parseOpenApi,
+ serializeContract,
+} from '../scripts/openapi-operations.ts';
+import type { ExtractedOperation, OpenApiDocument } from '../scripts/openapi-operations.ts';
+import { serveDocs, serveLocally } from './docs-server.ts';
+import { openApiFixture } from './global-setup.ts';
+import { dataDirUrl, jsonAt } from './helpers.ts';
+import { runBuildData } from './run-build-data.ts';
+import type { ScriptResult } from './run-build-data.ts';
+
+const LONG_TEXT = 'Long test text that inflates the schema. '.repeat(8);
+const SCRIPT_TIMEOUT_MS = 30_000;
+
+function hugeDocument(): OpenApiDocument {
+ const leafProperties = Object.fromEntries(
+ Array.from({ length: 30 }, (_, i) => [`field${i}`, { type: 'string', description: LONG_TEXT, example: LONG_TEXT }]),
+ );
+ const rootProperties = Object.fromEntries(Array.from({ length: 40 }, (_, i) => [`child${i}`, { $ref: '#/components/schemas/Child' }]));
+ return {
+ openapi: '3.1.1',
+ paths: {
+ '/api/v4/huge': {
+ post: {
+ operationId: 'createHuge',
+ tags: ['Tests'],
+ summary: 'Create something huge',
+ description: 'Huge operation. Docs: [huge](https://docs.fiscalapi.com/huge).',
+ requestBody: {
+ content: {
+ 'application/json': { schema: { $ref: '#/components/schemas/Huge' } },
+ 'text/json': { schema: { $ref: '#/components/schemas/Huge' } },
+ },
+ },
+ responses: { '200': { description: 'OK' } },
+ },
+ },
+ },
+ components: {
+ schemas: {
+ Huge: {
+ type: 'object',
+ description: 'Body root',
+ required: ['child0'],
+ properties: { ...rootProperties, state: { type: 'string', enum: ['A', 'B'], format: 'x-code' } },
+ },
+ Child: { type: ['null', 'object'], required: ['field0'], properties: leafProperties },
+ },
+ },
+ };
+}
+
+function operationOf(document: OpenApiDocument, operationId: string): ExtractedOperation {
+ const operation = extractOperations(document).find((candidate) => candidate.operationId === operationId);
+ if (operation === undefined) throw new Error(`Missing ${operationId} in the test document.`);
+ return operation;
+}
+
+function requestSchema(contract: unknown): unknown {
+ return jsonAt(contract, 'requestBody', 'content', 'application/json', 'schema');
+}
+
+describe('buildContract', () => {
+ it('trims a huge operation to the limit while keeping its structure and documentation', () => {
+ const document = hugeDocument();
+
+ const contract = buildContract(document, operationOf(document, 'createHuge'));
+ const json = serializeContract(contract);
+
+ expect(json.length).toBeLessThanOrEqual(MAX_CONTRACT_CHARS);
+ expect(contract.description).toContain('https://docs.fiscalapi.com/huge');
+ expect(contract.summary).toBe('Create something huge');
+ expect(json).not.toContain('"example"');
+ expect(json).not.toContain('"$ref"');
+ expect(Object.keys(Object(jsonAt(contract, 'requestBody', 'content')))).toEqual(['application/json']);
+ expect(jsonAt(requestSchema(contract), 'required')).toEqual(['child0']);
+ expect(jsonAt(requestSchema(contract), 'properties', 'state')).toEqual({ type: 'string', enum: ['A', 'B'], format: 'x-code' });
+ expect(Object.keys(Object(jsonAt(requestSchema(contract), 'properties')))).toHaveLength(41);
+ });
+
+ it('drops examples and nested descriptions before trimming subtrees', () => {
+ const document = hugeDocument();
+ const fullLength = serializeContract(buildContract(document, operationOf(document, 'createHuge'), Infinity)).length;
+
+ const contract = buildContract(document, operationOf(document, 'createHuge'), fullLength / 3);
+ const json = serializeContract(contract);
+
+ expect(json).not.toContain('x-truncated');
+ expect(json).not.toContain(LONG_TEXT);
+ expect(jsonAt(requestSchema(contract), 'description')).toBe('Body root');
+ expect(jsonAt(requestSchema(contract), 'properties', 'child0')).toMatchObject({ type: ['null', 'object'], required: ['field0'] });
+ expect(jsonAt(requestSchema(contract), 'properties', 'child0', 'properties', 'field29')).toEqual({ type: 'string' });
+ });
+
+ it('replaces subtrees with their schema name as a last resort', () => {
+ const document = hugeDocument();
+
+ const contract = buildContract(document, operationOf(document, 'createHuge'), 4_000);
+
+ expect(serializeContract(contract).length).toBeLessThanOrEqual(4_000);
+ expect(jsonAt(requestSchema(contract), 'properties', 'child0')).toEqual({ type: 'object', 'x-truncated': 'Child' });
+ expect(jsonAt(requestSchema(contract), 'properties', 'state')).toEqual({ type: 'string', enum: ['A', 'B'], format: 'x-code' });
+ });
+
+ it('fails when the operation does not fit even when trimmed', () => {
+ const document = hugeDocument();
+
+ expect(() => buildContract(document, operationOf(document, 'createHuge'), 100)).toThrow(OpenApiError);
+ });
+
+ it('cuts recursive schemas without looping', () => {
+ const document: OpenApiDocument = {
+ openapi: '3.1.1',
+ paths: {
+ '/api/v4/nodes': {
+ get: {
+ operationId: 'listNodes',
+ responses: { '200': { description: 'OK', content: { 'application/json': { schema: { $ref: '#/components/schemas/Node' } } } } },
+ },
+ },
+ },
+ components: {
+ schemas: { Node: { type: 'object', properties: { children: { type: 'array', items: { $ref: '#/components/schemas/Node' } } } } },
+ },
+ };
+
+ const contract = buildContract(document, operationOf(document, 'listNodes'));
+
+ const schema = jsonAt(contract, 'responses', '200', 'content', 'application/json', 'schema');
+ expect(jsonAt(schema, 'properties', 'children', 'items')).toEqual({ type: 'object', 'x-recursive': 'Node' });
+ });
+});
+
+describe('extractOperations and listApiOperations', () => {
+ it('rejects an operationId that could escape the contracts folder', () => {
+ const document: OpenApiDocument = { openapi: '3.1.1', paths: { '/x': { get: { operationId: '../x', responses: {} } } } };
+
+ expect(() => extractOperations(document)).toThrow(/no valid operationId/);
+ });
+
+ it('sorts by tag and then by operationId', () => {
+ const document: OpenApiDocument = {
+ openapi: '3.1.1',
+ paths: {
+ '/b': { get: { operationId: 'zeta', tags: ['B'], summary: 'z' } },
+ '/a': { get: { operationId: 'beta', tags: ['A'], summary: 'b' }, post: { operationId: 'alpha', tags: ['A'], summary: 'a' } },
+ },
+ };
+
+ expect(listApiOperations(extractOperations(document)).map((operation) => operation.operationId)).toEqual(['alpha', 'beta', 'zeta']);
+ });
+
+ it('rejects JSON that is not OpenAPI 3', () => {
+ expect(() => parseOpenApi('{"swagger":"2.0","paths":{}}')).toThrow(OpenApiError);
+ expect(() => parseOpenApi('')).toThrow(/not valid JSON/);
+ });
+});
+
+describe('scripts/build-data.ts', { timeout: SCRIPT_TIMEOUT_MS }, () => {
+ const workDir = mkdtempSync(path.join(tmpdir(), 'fiscalapi-docs-mcp-build-'));
+ const outDir = path.join(workDir, 'data');
+ afterAll(() => rmSync(workDir, { recursive: true, force: true }));
+
+ function runBuild(openApiSource: string, ...args: readonly string[]): Promise {
+ return runBuildData([openApiSource, ...args, '--out', outDir]);
+ }
+
+ function seedPreviousData(): string {
+ rmSync(outDir, { recursive: true, force: true });
+ mkdirSync(outDir, { recursive: true });
+ writeFileSync(path.join(outDir, DATA_FILES.source), '{}');
+ const marker = path.join(outDir, 'previous.txt');
+ writeFileSync(marker, 'previous data');
+ return marker;
+ }
+
+ function leftovers(): string[] {
+ return readdirSync(workDir).filter((name) => name.startsWith('data.'));
+ }
+
+ it('refuses to replace an existing directory that is not a data directory', async () => {
+ const unrelatedDir = path.join(workDir, 'unrelated');
+ mkdirSync(unrelatedDir, { recursive: true });
+ writeFileSync(path.join(unrelatedDir, 'keep.txt'), 'keep');
+
+ const { code, stderr } = await runBuildData([openApiFixture, '--out', unrelatedDir]);
+
+ expect(code).toBe(1);
+ expect(stderr).toContain(`refusing to replace ${unrelatedDir}`);
+ expect(readdirSync(unrelatedDir)).toEqual(['keep.txt']);
+ });
+
+ it('exits with code 1 when the OpenAPI URL does not respond 200', async () => {
+ const server = await serveLocally(() => ({ status: 401 }));
+ try {
+ const { code, stderr } = await runBuild(`${server.origin}/openapi.json`);
+
+ expect(code).toBe(1);
+ expect(stderr).toContain('responded HTTP 401; expected 200');
+ } finally {
+ await server.close();
+ }
+ });
+
+ it('exits with code 1 when the file is not OpenAPI', async () => {
+ const notOpenApi = path.join(workDir, 'not-openapi.json');
+ writeFileSync(notOpenApi, '{"hello":"world"}');
+
+ const { code, stderr } = await runBuild(notOpenApi);
+
+ expect(code).toBe(1);
+ expect(stderr).toContain('is not an OpenAPI 3 document');
+ });
+
+ it.each([
+ ['responds 404', { '/page/section-a.md': '## Section A\n\nText.' }, 'page/section-b.md responded HTTP 404; expected 200'],
+ [
+ 'returns HTML',
+ { '/page/section-a.md': '## Section A\n\nText.', '/page/section-b.md': '' },
+ 'page/section-b.md returned HTML instead of Markdown',
+ ],
+ ])('exits with code 1 and keeps the previous data when a .md section %s', async (_, files, message) => {
+ const docs = await serveDocs({ llms: SMALL_LLMS, files });
+ const marker = seedPreviousData();
+ try {
+ const { code, stderr } = await runBuild(openApiFixture, '--docs-url', docs.origin);
+
+ expect(code).toBe(1);
+ expect(stderr).toContain(`build-data: ${docs.origin}/${message}`);
+ expect(readFileSync(marker, 'utf8')).toBe('previous data');
+ expect(readdirSync(outDir).sort()).toEqual(['previous.txt', DATA_FILES.source].sort());
+ expect(leftovers()).toEqual([]);
+ } finally {
+ await docs.close();
+ }
+ });
+
+ it('retries a docs request rejected with HTTP 429 and completes the build', async () => {
+ const routes = new Map([
+ ['/llms.txt', SMALL_LLMS],
+ ['/page/section-a.md', '## Section A\n\nText.'],
+ ['/page/section-b.md', '## Section B\n\nText.'],
+ ]);
+ let rateLimitedRequests = 0;
+ const docs = await serveLocally((pathname) => {
+ if (pathname === '/page/section-a.md' && rateLimitedRequests === 0) {
+ rateLimitedRequests++;
+ return { status: 429 };
+ }
+ const body = routes.get(pathname);
+ return body === undefined ? { status: 404 } : { status: 200, body };
+ });
+ try {
+ const { code, stderr } = await runBuild(openApiFixture, '--docs-url', docs.origin);
+
+ expect(stderr).toContain('2 docs sections');
+ expect(code).toBe(0);
+ expect(rateLimitedRequests).toBe(1);
+ expect(existsSync(path.join(outDir, 'previous.txt'))).toBe(false);
+ expect(leftovers()).toEqual([]);
+ } finally {
+ await docs.close();
+ }
+ });
+
+ it('exits with code 1 when llms.txt does not exist', async () => {
+ const docs = await serveDocs({ llms: SMALL_LLMS, files: {} });
+ try {
+ const { code, stderr } = await runBuild(openApiFixture, '--docs-url', `${docs.origin}/missing`);
+
+ expect(code).toBe(1);
+ expect(stderr).toContain(`${docs.origin}/missing/llms.txt responded HTTP 404`);
+ } finally {
+ await docs.close();
+ }
+ });
+
+ it('records the OpenAPI and docs hashes and counts in the source manifest', () => {
+ const manifest: unknown = JSON.parse(readFileSync(new URL(DATA_FILES.source, dataDirUrl()), 'utf8'));
+
+ expect(jsonAt(manifest, 'openApi', 'operations')).toBe(100);
+ expect(jsonAt(manifest, 'openApi', 'sha256')).toMatch(/^[0-9a-f]{64}$/);
+ expect(jsonAt(manifest, 'docs', 'baseUrl')).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
+ expect(jsonAt(manifest, 'docs', 'sections')).toBe(383);
+ expect(jsonAt(manifest, 'docs', 'sha256')).toMatch(/^[0-9a-f]{64}$/);
+ expect(existsSync(new URL(DATA_FILES.docsIndex, dataDirUrl()))).toBe(true);
+ });
+});
+
+const SMALL_LLMS = [
+ '# FiscalAPI',
+ '',
+ '- Endpoints live under `/api/v4/`.',
+ '- [Page](https://docs.fiscalapi.com/page.md): description.',
+ ' - [Section A](https://docs.fiscalapi.com/page/section-a.md)',
+ ' - [Section B](https://docs.fiscalapi.com/page/section-b.md)',
+].join('\n');
+
+describe('parseLlms', () => {
+ it('indexes each section with its page and whole pages without sections, without repeating URLs', () => {
+ const llms = [
+ SMALL_LLMS,
+ '',
+ '## Reference',
+ '',
+ '- [Single](https://docs.fiscalapi.com/single.md): page without sections.',
+ '- [Other](https://docs.fiscalapi.com/other.md): repeats a section.',
+ ' - [Section A](https://docs.fiscalapi.com/page/section-a.md)',
+ ' - [Own](https://docs.fiscalapi.com/other/own.md)',
+ '- [Single](https://docs.fiscalapi.com/single.md): the same page again.',
+ '- [External](https://example.com/external.md): another site.',
+ ].join('\n');
+
+ expect(parseLlms(llms)).toEqual([
+ { url: 'https://docs.fiscalapi.com/page/section-a.md', page: 'Page' },
+ { url: 'https://docs.fiscalapi.com/page/section-b.md', page: 'Page' },
+ { url: 'https://docs.fiscalapi.com/single.md', page: 'Single' },
+ { url: 'https://docs.fiscalapi.com/other/own.md', page: 'Other' },
+ ]);
+ });
+
+ it('fails when llms.txt links no .md page of the site', () => {
+ expect(() => parseLlms('')).toThrow(DocsError);
+ });
+});
+
+describe('cleanSection', () => {
+ const section = [
+ "## Cancelar factura {{ tag: 'DELETE', label: '/api/v4/invoices' }}",
+ '',
+ ' Este endpoint cancela una factura. Revisa los [valores](/modes-of-operation) y `sat.blacklist.69b`.',
+ ' ',
+ ' ',
+ ' Folio fiscal (UUID) de la factura.',
+ ' ',
+ '',
+ '',
+ '',
+ `
- Comprobante emitido con errores **con** relación
`,
+ ' - Certificado CSD
',
+ ' ',
+ '',
+ '```bash',
+ '# Not a heading',
+ 'curl -X DELETE https://test.fiscalapi.com/api/v4/invoices',
+ '```',
+ '',
+ '| Código | Descripción |',
+ '| --- | --- |',
+ '| CFDI40147 | El campo DomicilioFiscalReceptor |',
+ ].join('\n');
+
+ it('splits the title and the endpoint annotation', () => {
+ const { title, endpoint } = cleanSection(section);
+
+ expect(title).toBe('Cancelar factura');
+ expect(endpoint).toBe('DELETE /api/v4/invoices');
+ });
+
+ it('removes code, tags, URLs and indentation while keeping tag names and values, line breaks and tables', () => {
+ expect(cleanSection(section).text).toBe(
+ [
+ 'Este endpoint cancela una factura. Revisa los valores y sat.blacklist.69b.',
+ '',
+ 'invoiceUuid',
+ 'Folio fiscal (UUID) de la factura.',
+ '',
+ '01 Comprobante emitido con errores **con** relación',
+ '0 Certificado CSD',
+ '',
+ '| Código | Descripción |',
+ '| --- | --- |',
+ '| CFDI40147 | El campo DomicilioFiscalReceptor |',
+ ].join('\n'),
+ );
+ });
+
+ it('has no endpoint without an annotation', () => {
+ expect(cleanSection('# Webhooks\n\nTexto.')).toEqual({ title: 'Webhooks', endpoint: '', text: 'Texto.' });
+ });
+});
diff --git a/docs-mcp/test/docs-server.ts b/docs-mcp/test/docs-server.ts
new file mode 100644
index 0000000..17553a8
--- /dev/null
+++ b/docs-mcp/test/docs-server.ts
@@ -0,0 +1,56 @@
+import { readFileSync } from 'node:fs';
+import { createServer } from 'node:http';
+import type { AddressInfo } from 'node:net';
+import { gunzipSync } from 'node:zlib';
+
+import * as z from 'zod/v4';
+
+const docsCorpusSchema = z.object({
+ llms: z.string(),
+ files: z.record(z.string(), z.string()),
+});
+
+export type DocsCorpus = z.infer;
+
+export interface LocalServer {
+ readonly origin: string;
+ close(): Promise;
+}
+
+const LOOPBACK_HOST = '127.0.0.1';
+const LOOPBACK_ORIGIN = `http://${LOOPBACK_HOST}`;
+const corpusFile = new URL('fixtures/docs-corpus.json.gz', import.meta.url);
+
+export function readDocsCorpus(): DocsCorpus {
+ return docsCorpusSchema.parse(JSON.parse(gunzipSync(readFileSync(corpusFile)).toString('utf8')));
+}
+
+export interface LocalResponse {
+ readonly status: number;
+ readonly body?: string;
+}
+
+export function serveDocs(corpus: DocsCorpus): Promise {
+ const routes = new Map(Object.entries({ ...corpus.files, '/llms.txt': corpus.llms }));
+ return serveLocally((pathname) => {
+ const body = routes.get(pathname);
+ return body === undefined ? { status: 404 } : { status: 200, body };
+ });
+}
+
+export async function serveLocally(respond: (pathname: string) => LocalResponse): Promise {
+ const server = createServer((request, response) => {
+ const { status, body } = respond(new URL(request.url ?? '/', LOOPBACK_ORIGIN).pathname);
+ response.writeHead(status, { 'content-type': 'text/markdown; charset=utf-8' }).end(body);
+ });
+ await new Promise((resolve) => server.listen(0, LOOPBACK_HOST, resolve));
+ return {
+ origin: `http://${LOOPBACK_HOST}:${portOf(server.address())}`,
+ close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))),
+ };
+}
+
+function portOf(address: AddressInfo | string | null): number {
+ if (address === null || typeof address === 'string') throw new Error('Expected a TCP address.');
+ return address.port;
+}
diff --git a/docs-mcp/test/excerpt.test.ts b/docs-mcp/test/excerpt.test.ts
new file mode 100644
index 0000000..6f51048
--- /dev/null
+++ b/docs-mcp/test/excerpt.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from 'vitest';
+
+import { processTerm } from '../src/docs-index-options.ts';
+import { excerpt, MAX_EXCERPT_CHARS } from '../src/excerpt.ts';
+
+function termsOf(word: string): string[] {
+ const term = processTerm(word);
+ return term === null ? [] : [term];
+}
+
+describe('excerpt', () => {
+ it('returns short text unchanged when it matches at the start', () => {
+ expect(excerpt('Cancelar factura ante el SAT.', termsOf('Cancelar'), termsOf('Cancelar'))).toBe('Cancelar factura ante el SAT.');
+ });
+
+ it('starts near the first exact match and marks both cuts with an ellipsis', () => {
+ const filler = Array.from({ length: 400 }, (_, i) => `palabra${i}`).join(' ');
+ const text = `${filler} CFDI40147 ${filler}`;
+
+ const result = excerpt(text, termsOf('CFDI40147'), termsOf('CFDI40147'));
+
+ expect(result.startsWith('…')).toBe(true);
+ expect(result.endsWith('…')).toBe(true);
+ expect(result).toContain('CFDI40147');
+ expect(result.length).toBeLessThanOrEqual(MAX_EXCERPT_CHARS);
+ });
+
+ it('prefers an exact match over an earlier fuzzy match', () => {
+ const filler = Array.from({ length: 200 }, (_, i) => `relleno${i}`).join(' ');
+ const text = `CFDI40101 ${filler} CFDI40147 final`;
+
+ const result = excerpt(text, [...termsOf('CFDI40101'), ...termsOf('CFDI40147')], termsOf('CFDI40147'));
+
+ expect(result).toContain('CFDI40147');
+ expect(result).not.toContain('CFDI40101');
+ });
+
+ it('cuts inside a single token longer than the limit instead of dropping it', () => {
+ const longToken = 'x'.repeat(MAX_EXCERPT_CHARS * 2);
+
+ const result = excerpt(`intro ${longToken} fin`, termsOf('intro'), termsOf('intro'));
+
+ expect(result.length).toBeLessThanOrEqual(MAX_EXCERPT_CHARS);
+ expect(result.length).toBeGreaterThan(MAX_EXCERPT_CHARS / 2);
+ });
+
+ it('returns an empty string for empty text', () => {
+ expect(excerpt(' ', [], [])).toBe('');
+ });
+});
diff --git a/mcp/test/fixtures/docs-corpus.json.gz b/docs-mcp/test/fixtures/docs-corpus.json.gz
similarity index 100%
rename from mcp/test/fixtures/docs-corpus.json.gz
rename to docs-mcp/test/fixtures/docs-corpus.json.gz
diff --git a/mcp/test/fixtures/openapi-v4.json b/docs-mcp/test/fixtures/openapi-v4.json
similarity index 100%
rename from mcp/test/fixtures/openapi-v4.json
rename to docs-mcp/test/fixtures/openapi-v4.json
diff --git a/docs-mcp/test/global-setup.ts b/docs-mcp/test/global-setup.ts
new file mode 100644
index 0000000..40a9104
--- /dev/null
+++ b/docs-mcp/test/global-setup.ts
@@ -0,0 +1,30 @@
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+
+import type { TestProject } from 'vitest/node';
+
+import { readDocsCorpus, serveDocs } from './docs-server.ts';
+import { runBuildData } from './run-build-data.ts';
+
+export const openApiFixture = fileURLToPath(new URL('fixtures/openapi-v4.json', import.meta.url));
+
+export default async function setup(project: TestProject): Promise<() => void> {
+ const workDir = mkdtempSync(path.join(tmpdir(), 'fiscalapi-docs-mcp-'));
+ const dataDir = path.join(workDir, 'data');
+ const docs = await serveDocs(readDocsCorpus());
+ const build = await runBuildData([openApiFixture, '--docs-url', docs.origin, '--out', dataDir]).finally(() => docs.close());
+ if (build.code !== 0) {
+ rmSync(workDir, { recursive: true, force: true });
+ throw new Error(`build-data failed with the fixtures:\n${build.stderr}`);
+ }
+ project.provide('dataDir', pathToFileURL(dataDir + path.sep).href);
+ return () => rmSync(workDir, { recursive: true, force: true });
+}
+
+declare module 'vitest' {
+ export interface ProvidedContext {
+ dataDir: string;
+ }
+}
diff --git a/docs-mcp/test/helpers.ts b/docs-mcp/test/helpers.ts
new file mode 100644
index 0000000..e817acb
--- /dev/null
+++ b/docs-mcp/test/helpers.ts
@@ -0,0 +1,53 @@
+import { Client, InMemoryTransport } from '@modelcontextprotocol/client';
+import type { CallToolResult } from '@modelcontextprotocol/client';
+import { inject } from 'vitest';
+
+import { ApiOperations } from '../src/api-operations.ts';
+import { DocsIndex } from '../src/docs-index.ts';
+import { createServer } from '../src/server.ts';
+
+export interface ConnectedClient {
+ readonly client: Client;
+ close(): Promise;
+}
+
+export function dataDirUrl(): URL {
+ return new URL(inject('dataDir'));
+}
+
+export async function connectClient(): Promise {
+ const server = createServer({
+ operations: ApiOperations.load(dataDirUrl()),
+ docs: DocsIndex.load(dataDirUrl()),
+ version: '0.0.0-test',
+ });
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
+ const client = new Client({ name: 'fiscalapi-docs-mcp-test', version: '1.0.0' });
+ await server.connect(serverTransport);
+ await client.connect(clientTransport);
+ return {
+ client,
+ close: async () => {
+ await client.close();
+ await server.close();
+ },
+ };
+}
+
+export function callTool(client: Client, name: string, args: Record = {}): Promise {
+ return client.callTool({ name, arguments: args });
+}
+
+export function textOf(result: CallToolResult): string {
+ const [block] = result.content;
+ if (block?.type !== 'text') throw new Error('Expected a text content block.');
+ return block.text;
+}
+
+export function jsonAt(value: unknown, ...path: readonly string[]): unknown {
+ return path.reduce((current, key) => (isRecord(current) ? current[key] : undefined), value);
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
diff --git a/docs-mcp/test/http.test.ts b/docs-mcp/test/http.test.ts
new file mode 100644
index 0000000..9ffd366
--- /dev/null
+++ b/docs-mcp/test/http.test.ts
@@ -0,0 +1,85 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { fetchText, HttpError, RETRY_POLICY } from '../scripts/http.ts';
+
+const requestedDelays = vi.hoisted((): number[] => []);
+
+vi.mock('node:timers/promises', () => ({
+ setTimeout: vi.fn(async (milliseconds: number) => {
+ requestedDelays.push(milliseconds);
+ }),
+}));
+
+const URL_UNDER_TEST = 'https://docs.example.test/page.md';
+
+function respondWith(...responses: ReadonlyArray) {
+ const fetchMock = vi.fn(async () => {
+ const next = responses[Math.min(fetchMock.mock.calls.length - 1, responses.length - 1)];
+ if (next instanceof Error) throw next;
+ return next;
+ });
+ vi.stubGlobal('fetch', fetchMock);
+ return fetchMock;
+}
+
+beforeEach(() => {
+ vi.spyOn(Math, 'random').mockReturnValue(0);
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ requestedDelays.length = 0;
+});
+
+describe('fetchText', () => {
+ it('returns the body of a 200 response', async () => {
+ respondWith(new Response('ok'));
+
+ await expect(fetchText(URL_UNDER_TEST)).resolves.toBe('ok');
+ expect(requestedDelays).toEqual([]);
+ });
+
+ it('retries rate limiting and network errors with exponential backoff', async () => {
+ const fetchMock = respondWith(new Response(null, { status: 429 }), new TypeError('fetch failed'), new Response('ok'));
+
+ await expect(fetchText(URL_UNDER_TEST)).resolves.toBe('ok');
+ expect(fetchMock).toHaveBeenCalledTimes(3);
+ expect(requestedDelays).toEqual([RETRY_POLICY.initialBackoffMs, RETRY_POLICY.initialBackoffMs * 2]);
+ });
+
+ it('honors Retry-After up to the maximum backoff', async () => {
+ respondWith(
+ new Response(null, { status: 503, headers: { 'retry-after': '2' } }),
+ new Response(null, { status: 503, headers: { 'retry-after': '3600' } }),
+ new Response('ok'),
+ );
+
+ await expect(fetchText(URL_UNDER_TEST)).resolves.toBe('ok');
+ expect(requestedDelays).toEqual([2_000, RETRY_POLICY.maxBackoffMs]);
+ });
+
+ it('does not retry a client error', async () => {
+ const fetchMock = respondWith(new Response(null, { status: 404 }));
+
+ await expect(fetchText(URL_UNDER_TEST)).rejects.toThrow(`${URL_UNDER_TEST} responded HTTP 404; expected 200.`);
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('gives up after the maximum number of attempts', async () => {
+ const fetchMock = respondWith(new Response(null, { status: 503 }));
+
+ await expect(fetchText(URL_UNDER_TEST)).rejects.toThrow(HttpError);
+ expect(fetchMock).toHaveBeenCalledTimes(RETRY_POLICY.maxAttempts);
+ expect(requestedDelays).toHaveLength(RETRY_POLICY.maxAttempts - 1);
+ });
+
+ it('stops when its signal is aborted', async () => {
+ const controller = new AbortController();
+ controller.abort(new Error('cancelled'));
+ const fetchMock = respondWith(new Response('ok'));
+
+ await expect(fetchText(URL_UNDER_TEST, controller.signal)).rejects.toThrow('cancelled');
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/docs-mcp/test/offline.test.ts b/docs-mcp/test/offline.test.ts
new file mode 100644
index 0000000..3463a70
--- /dev/null
+++ b/docs-mcp/test/offline.test.ts
@@ -0,0 +1,30 @@
+import { afterEach, expect, it, vi } from 'vitest';
+
+import { TOOL_NAMES } from '../src/tools/tool-conventions.ts';
+import { callTool, connectClient, jsonAt, textOf } from './helpers.ts';
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+it('loads its data and answers every tool without calling fetch', async () => {
+ const fetch = vi.fn(() => {
+ throw new Error('The server must not use the network.');
+ });
+ vi.stubGlobal('fetch', fetch);
+
+ const { client, close } = await connectClient();
+ try {
+ const { tools } = await client.listTools();
+ const contract = await callTool(client, TOOL_NAMES.getOperation, { operationId: 'createInvoice' });
+ const docs = await callTool(client, TOOL_NAMES.searchDocs, { query: 'cancelar factura' });
+
+ expect(tools).toHaveLength(Object.keys(TOOL_NAMES).length);
+ expect(jsonAt(JSON.parse(textOf(contract)), 'operationId')).toBe('createInvoice');
+ expect(docs.isError).toBeFalsy();
+ expect(textOf(docs)).toContain('https://docs.fiscalapi.com/');
+ expect(fetch).not.toHaveBeenCalled();
+ } finally {
+ await close();
+ }
+});
diff --git a/docs-mcp/test/run-build-data.ts b/docs-mcp/test/run-build-data.ts
new file mode 100644
index 0000000..f109dd7
--- /dev/null
+++ b/docs-mcp/test/run-build-data.ts
@@ -0,0 +1,22 @@
+import { spawn } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+
+export interface ScriptResult {
+ readonly code: number | null;
+ readonly stderr: string;
+}
+
+const packageDir = fileURLToPath(new URL('../', import.meta.url));
+
+export function runBuildData(args: readonly string[]): Promise {
+ return new Promise((resolve, reject) => {
+ const child = spawn(process.execPath, ['scripts/build-data.ts', ...args], { cwd: packageDir });
+ let stderr = '';
+ child.stderr.setEncoding('utf8');
+ child.stderr.on('data', (chunk: string) => {
+ stderr += chunk;
+ });
+ child.on('error', reject);
+ child.on('close', (code) => resolve({ code, stderr }));
+ });
+}
diff --git a/docs-mcp/test/search-docs-tool.test.ts b/docs-mcp/test/search-docs-tool.test.ts
new file mode 100644
index 0000000..2a74962
--- /dev/null
+++ b/docs-mcp/test/search-docs-tool.test.ts
@@ -0,0 +1,107 @@
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+
+import { MAX_EXCERPT_CHARS } from '../src/excerpt.ts';
+import { SEARCH_LIMITS } from '../src/tools/search-docs-tool.ts';
+import { TOOL_NAMES } from '../src/tools/tool-conventions.ts';
+import { callTool, connectClient, textOf } from './helpers.ts';
+import type { ConnectedClient } from './helpers.ts';
+
+const MAX_RESULT_OVERHEAD_CHARS = 400;
+const MAX_RESPONSE_CHARS = SEARCH_LIMITS.maxResults * (MAX_EXCERPT_CHARS + MAX_RESULT_OVERHEAD_CHARS);
+const RESULT_URL = /^https:\/\/docs\.fiscalapi\.com\/\S+\.md$/gm;
+
+let connection: ConnectedClient;
+
+beforeAll(async () => {
+ connection = await connectClient();
+});
+
+afterAll(async () => {
+ await connection.close();
+});
+
+function searchDocs(args: Record) {
+ return callTool(connection.client, TOOL_NAMES.searchDocs, args);
+}
+
+describe(TOOL_NAMES.searchDocs, () => {
+ it('is listed as a read-only tool that requires a query', async () => {
+ const { tools } = await connection.client.listTools();
+
+ const tool = tools.find((candidate) => candidate.name === TOOL_NAMES.searchDocs);
+ expect(tool?.title).toBe('Buscar en la documentación de FiscalAPI');
+ expect(tool?.annotations).toMatchObject({ readOnlyHint: true, destructiveHint: false, openWorldHint: false });
+ expect(tool?.inputSchema.required).toEqual(['query']);
+ });
+
+ it('returns the table row of a PAC error code', async () => {
+ const result = await searchDocs({ query: 'CFDI40147' });
+ const text = textOf(result);
+
+ expect(result.isError).toBeFalsy();
+ expect(text).toMatch(/^FiscalAPI docs: \d+ resultados para "CFDI40147"\./);
+ expect(text).toContain('https://docs.fiscalapi.com/errors/cfdi-40/errores-cfdi-4-0.md');
+ expect(text).toContain('| CFDI40147 |');
+ });
+
+ it('honors limit', async () => {
+ const text = textOf(await searchDocs({ query: 'factura', limit: 2 }));
+
+ expect(text.match(RESULT_URL)).toHaveLength(2);
+ });
+
+ it(`returns ${SEARCH_LIMITS.defaultResults} results by default`, async () => {
+ const text = textOf(await searchDocs({ query: 'factura' }));
+
+ expect(text.match(RESULT_URL)).toHaveLength(SEARCH_LIMITS.defaultResults);
+ });
+
+ it('titles a section with its page in parentheses', async () => {
+ const text = textOf(await searchDocs({ query: 'cancelar una factura', limit: 1 }));
+
+ expect(text).toMatch(/^FiscalAPI docs: 1 resultado para "cancelar una factura"\./);
+ expect(text).toMatch(/^## Cancelar factura \(.+\)$/m);
+ });
+
+ it('does not repeat the page title when the result is a whole page', async () => {
+ const text = textOf(await searchDocs({ query: 'catálogos sat', limit: 1 }));
+
+ expect(text).toContain('## Catálogos SAT\nhttps://docs.fiscalapi.com/catalogs-info.md\n');
+ });
+
+ it.each(['factura', 'CFDI40147', 'modelo factura', 'catálogo'])('with the maximum limit, "%s" fits the size limit', async (query) => {
+ const text = textOf(await searchDocs({ query, limit: SEARCH_LIMITS.maxResults }));
+
+ expect(text.length).toBeLessThanOrEqual(MAX_RESPONSE_CHARS);
+ });
+
+ it('answers without error and suggests rephrasing when nothing matches', async () => {
+ const result = await searchDocs({ query: 'zzzqqq' });
+
+ expect(result.isError).toBeFalsy();
+ expect(textOf(result)).toContain('no hay resultados para "zzzqqq"');
+ expect(textOf(result)).toContain(TOOL_NAMES.listOperations);
+ });
+
+ it('truncates the echoed query', async () => {
+ const echoed = SEARCH_LIMITS.maxEchoedQueryChars;
+ const text = textOf(await searchDocs({ query: `${'a'.repeat(echoed + 50)} zzzqqq` }));
+
+ expect(text).toContain(`"${'a'.repeat(echoed)}…"`);
+ expect(text).not.toContain('a'.repeat(echoed + 1));
+ });
+
+ it.each([
+ ['limit 0', { query: 'factura', limit: 0 }],
+ ['a limit above the maximum', { query: 'factura', limit: SEARCH_LIMITS.maxResults + 1 }],
+ ['a non-integer limit', { query: 'factura', limit: 2.5 }],
+ ['an empty query', { query: '' }],
+ ['a query above the maximum length', { query: 'a'.repeat(SEARCH_LIMITS.maxQueryChars + 1) }],
+ ['a missing query', {}],
+ ])('rejects %s', async (_, args) => {
+ const result = await searchDocs(args);
+
+ expect(result.isError).toBe(true);
+ expect(textOf(result)).not.toContain('FiscalAPI docs:');
+ });
+});
diff --git a/docs-mcp/test/search-regression.test.ts b/docs-mcp/test/search-regression.test.ts
new file mode 100644
index 0000000..2f04554
--- /dev/null
+++ b/docs-mcp/test/search-regression.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from 'vitest';
+
+import { DocsIndex } from '../src/docs-index.ts';
+import { TOOL_NAMES } from '../src/tools/tool-conventions.ts';
+import { dataDirUrl } from './helpers.ts';
+
+const TOP_RESULTS = 3;
+
+const EXPECTED_SECTIONS: ReadonlyArray = [
+ ['cancelar una factura', /\/cancelar-factura\.md$/],
+ ['complemento de pago', /crear-factura-de-pago|errores-pagos/],
+ ['descargar xml', /obtener-xml-por-id|listar-xmls-por-id|descargar-xml-y-pdf/],
+ ['crear cliente receptor', /recipients\/crear-receptor/],
+ ['subir certificado csd', /subir-certificados/],
+ ['nómina empleado', /employee-data\/|crear-factura-de-nomina/],
+ ['webhook', /\/webhooks\//],
+ ['saldo de timbres', /\/stamps\//],
+ ['estatus ante el sat', /consultar-estado-de-factura|sat-validations\/validar\.md|los-siete-criterios/],
+ ['descarga masiva', /download-info|download-requests\//],
+ ['lista negra 69b', /sat-validations/],
+ ['factura global', /factura-global|global-invoice/],
+ ['enviar factura por correo', /enviar-factura-por-correo/],
+ ['CFDI40147', /errores-cfdi-4-0/],
+ ['objeto de impuesto', /objeto-imp/],
+];
+
+const docs = DocsIndex.load(dataDirUrl());
+
+describe(`${TOOL_NAMES.searchDocs} relevance regression`, () => {
+ it.each(EXPECTED_SECTIONS)('"%s" returns the expected section in the top 3', (query, expectedUrl) => {
+ const topUrls = docs.search(query, TOP_RESULTS).map((result) => result.url);
+
+ expect(topUrls).toEqual(expect.arrayContaining([expect.stringMatching(expectedUrl)]));
+ });
+});
diff --git a/docs-mcp/test/tools.test.ts b/docs-mcp/test/tools.test.ts
new file mode 100644
index 0000000..efed9ea
--- /dev/null
+++ b/docs-mcp/test/tools.test.ts
@@ -0,0 +1,131 @@
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+
+import { MAX_CONTRACT_CHARS } from '../scripts/openapi-operations.ts';
+import { ApiOperations } from '../src/api-operations.ts';
+import { TOOL_NAMES } from '../src/tools/tool-conventions.ts';
+import { callTool, connectClient, dataDirUrl, jsonAt, textOf } from './helpers.ts';
+import type { ConnectedClient } from './helpers.ts';
+
+const MAX_OPERATION_LIST_CHARS = 12_000;
+const OPERATION_LINE = /^\w+ {2}(GET|POST|PUT|PATCH|DELETE) \/\S* {2}.+$/;
+
+const operations = ApiOperations.load(dataDirUrl()).list();
+
+let connection: ConnectedClient;
+
+beforeAll(async () => {
+ connection = await connectClient();
+});
+
+afterAll(async () => {
+ await connection.close();
+});
+
+function operationLines(text: string): string[] {
+ return text.split('\n').filter((line) => OPERATION_LINE.test(line));
+}
+
+describe('tools/list', () => {
+ it('exposes exactly the three read-only tools with Spanish descriptions that point to the next tool', async () => {
+ const { tools } = await connection.client.listTools();
+
+ expect(tools.map((tool) => tool.name).sort()).toEqual(Object.values(TOOL_NAMES).sort());
+ const byName = new Map(tools.map((tool) => [tool.name, tool]));
+ expect(byName.get(TOOL_NAMES.listOperations)?.description).toContain(`después llama a ${TOOL_NAMES.getOperation}`);
+ expect(byName.get(TOOL_NAMES.getOperation)?.description).toContain(`operationId exacto que devuelve ${TOOL_NAMES.listOperations}`);
+ expect(byName.get(TOOL_NAMES.getOperation)?.inputSchema.required).toEqual(['operationId']);
+ for (const tool of tools) {
+ expect(tool.annotations?.readOnlyHint).toBe(true);
+ expect(tool.description).toContain('no llama a la API y no requiere API key');
+ expect(tool.description).not.toMatch(/[—→✓]/);
+ }
+ });
+});
+
+describe(TOOL_NAMES.listOperations, () => {
+ it('lists the 100 operations grouped by tag within the size limit', async () => {
+ const result = await callTool(connection.client, TOOL_NAMES.listOperations);
+ const text = textOf(result);
+
+ expect(result.isError).toBeFalsy();
+ expect(operationLines(text)).toHaveLength(100);
+ expect(text).toContain('createInvoice POST /api/v4/invoices Emitir (timbrar) una factura CFDI 4.0');
+ expect(text).toContain('## Invoices');
+ expect(text.length).toBeLessThanOrEqual(MAX_OPERATION_LIST_CHARS);
+ });
+
+ it('filters by tag ignoring case', async () => {
+ const invoiceIds = operations.filter((operation) => operation.tag === 'Invoices').map((operation) => operation.operationId);
+
+ const text = textOf(await callTool(connection.client, TOOL_NAMES.listOperations, { tag: 'invoices' }));
+
+ expect(operationLines(text).map((line) => line.split(' ')[0])).toEqual(invoiceIds);
+ expect(text).not.toContain('## People');
+ });
+
+ it('returns an error with the available tags when the tag does not exist', async () => {
+ const result = await callTool(connection.client, TOOL_NAMES.listOperations, { tag: 'Facturas' });
+
+ expect(result.isError).toBe(true);
+ expect(textOf(result)).toContain('No hay operaciones con el tag "Facturas"');
+ expect(textOf(result)).toContain('Invoices');
+ });
+});
+
+describe(TOOL_NAMES.getOperation, () => {
+ it('returns createInvoice with its resolved request body within the size limit', async () => {
+ const result = await callTool(connection.client, TOOL_NAMES.getOperation, { operationId: 'createInvoice' });
+ const text = textOf(result);
+ const contract: unknown = JSON.parse(text);
+
+ expect(result.isError).toBeFalsy();
+ expect(contract).toMatchObject({ operationId: 'createInvoice', method: 'POST', path: '/api/v4/invoices' });
+ expect(jsonAt(contract, 'description')).toContain('https://docs.fiscalapi.com/');
+ const itemProperties = jsonAt(contract, 'requestBody', 'content', 'application/json', 'schema', 'properties', 'items', 'items', 'properties');
+ expect(itemProperties).toHaveProperty('itemCode');
+ expect(text).not.toContain('"$ref"');
+ expect(text.length).toBeLessThanOrEqual(MAX_CONTRACT_CHARS);
+ });
+
+ it.each(operations.map((operation) => operation.operationId))('%s fits the size limit and has no unresolved $ref', async (operationId) => {
+ const result = await callTool(connection.client, TOOL_NAMES.getOperation, { operationId });
+ const text = textOf(result);
+
+ expect(result.isError).toBeFalsy();
+ expect(jsonAt(JSON.parse(text), 'operationId')).toBe(operationId);
+ expect(text).not.toContain('"$ref"');
+ expect(text.length).toBeLessThanOrEqual(MAX_CONTRACT_CHARS);
+ });
+
+ it('suggests the right id for a typo', async () => {
+ const result = await callTool(connection.client, TOOL_NAMES.getOperation, { operationId: 'createInvoce' });
+
+ expect(result.isError).toBe(true);
+ expect(textOf(result)).toContain('No existe la operación "createInvoce"');
+ expect(textOf(result)).toContain('¿Quisiste decir createInvoice?');
+ });
+
+ it('suggests nothing without enough similarity and points to the list tool', async () => {
+ const result = await callTool(connection.client, TOOL_NAMES.getOperation, { operationId: 'xyz' });
+
+ expect(result.isError).toBe(true);
+ expect(textOf(result)).toBe(`No existe la operación "xyz". Usa ${TOOL_NAMES.listOperations} para ver los operationId disponibles.`);
+ });
+
+ it('suggests ids starting with the input before ids containing it', async () => {
+ const result = await callTool(connection.client, TOOL_NAMES.getOperation, { operationId: 'getinvoice' });
+
+ expect(result.isError).toBe(true);
+ expect(textOf(result)).toContain('¿Quisiste decir getInvoice, getInvoicePdf, getInvoiceStatus, getInvoiceXml?');
+ });
+
+ it.each(['../package', '../operations', '..\\operations', '/etc/passwd'])('rejects %s without reading files outside the contracts folder', async (operationId) => {
+ const result = await callTool(connection.client, TOOL_NAMES.getOperation, { operationId });
+ const text = textOf(result);
+
+ expect(result.isError).toBe(true);
+ expect(text).toContain('No existe la operación');
+ expect(text).not.toContain('"summary"');
+ expect(text).not.toContain('@fiscalapi/docs-mcp');
+ });
+});
diff --git a/docs-mcp/tsconfig.build.json b/docs-mcp/tsconfig.build.json
new file mode 100644
index 0000000..507d409
--- /dev/null
+++ b/docs-mcp/tsconfig.build.json
@@ -0,0 +1,9 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "noEmit": false,
+ "rootDir": "src",
+ "outDir": "dist"
+ },
+ "include": ["src"]
+}
diff --git a/docs-mcp/tsconfig.json b/docs-mcp/tsconfig.json
new file mode 100644
index 0000000..5833d34
--- /dev/null
+++ b/docs-mcp/tsconfig.json
@@ -0,0 +1,23 @@
+{
+ "compilerOptions": {
+ "target": "ES2024",
+ "lib": ["ES2024"],
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "types": ["node"],
+ "strict": true,
+ "noUncheckedIndexedAccess": true,
+ "exactOptionalPropertyTypes": true,
+ "noImplicitOverride": true,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "verbatimModuleSyntax": true,
+ "erasableSyntaxOnly": true,
+ "rewriteRelativeImportExtensions": true,
+ "skipLibCheck": true,
+ "noEmit": true
+ },
+ "include": ["src", "scripts", "test", "vitest.config.ts", "../scripts"]
+}
diff --git a/mcp/vitest.config.ts b/docs-mcp/vitest.config.ts
similarity index 100%
rename from mcp/vitest.config.ts
rename to docs-mcp/vitest.config.ts
diff --git a/docs/ai-integracion/README.md b/docs/ai-integracion/README.md
deleted file mode 100644
index 7eab265..0000000
--- a/docs/ai-integracion/README.md
+++ /dev/null
@@ -1,66 +0,0 @@
-# Integración asistida por IA
-
-## Objetivo
-Que un desarrollador integre FiscalAPI en su ERP, POS o SaaS desde Claude Code, Codex, Cursor o Copilot, y logre su primer timbre correcto en test al primer intento.
-
-Para eso el feature publica:
-- skills en español;
-- un plugin para los 4 hosts;
-- un MCP stdio opcional;
-- los cimientos que esos tres necesitan: OpenAPI público y docs por sección.
-
-**Cómo se sabe que quedó:**
-- Las evals muestran mejora con las skills frente a sin ellas.
-- Baja el tiempo al primer timbre en test (métrica norte en `requisitos.md`).
-
-**Ramas:** `feat/ai-integracion` en cada repo que toque (fiscalapi-ai, fapi-back, fapi-docs, fapi-reusables), siempre desde `main`.
-
-**Fuera de alcance:** MCP hosteado, sandbox sin "compra", CSD de prueba propios y el top de rechazos del PAC en Seq (MED-03, D-20). Ver `requisitos.md`.
-
-## Cómo trabajar
-Sigue el ciclo de `fapi-core` (sección 1). Particularidades de este feature:
-- **El tablero vive aquí, en fiscalapi-ai,** porque el feature se escribe casi todo en este repo (decisión del usuario). El código de las tareas `BE-*` va en fapi-back y el de las `DOC-*` en fapi-docs o fapi-reusables; la fila de esas tareas se cierra con `@`.
-- **Restricción dura: cero mantenimiento adicional para FiscalAPI (D-02).** Si una tarea agrega algo que hay que actualizar a mano en paralelo a las docs o al código, está mal planteada.
-- **fiscalapi-ai es público.** Nada llega a `main` sin el PR que fusiona el usuario. Publicar en npm o en los marketplaces es un paso del usuario (U-04).
-- **Todo en español:** skills, descripciones de herramientas del MCP y docs (D-04).
-- **Entorno local:** fapi-back y fapi-docs se trabajan en los worktrees `../fapi-back-ai` y `../fapi-docs-ai` (rama `feat/ai-integracion`, ya con `npm ci` en fapi-docs-ai), para no tocar la rama `feat/localization` de sus working trees principales. fapi-reusables sigue en `feat/auto-invoice` con cambios del usuario; lo del feature va en el worktree `../fapi-reusables-ai` (rama `feat/ai-integracion` desde `origin/main`). fapi-docs tiene `user.name` local "Fiscalapi": todo commit ahí lleva `-c user.name=mendozagit -c user.email=mendoza.git@gmail.com`.
-- **Reanudar:** relee este tablero y vuelve a lanzar `/loop` con el prompt del feature; toma la siguiente fila según `fapi-core`. Nada queda a medias fuera de git.
-
-## Tablero
-Estado ∈ `pendiente` | `en curso` | `hecho` | `bloqueado`. En `bloqueado`, el motivo o la pregunta va en las Notas de la tarea.
-
-La siguiente tarea (global o de un área) es la primera fila `pendiente` (de esa área) cuyas dependencias sean `—` o `hecho`. El orden de las filas es la prioridad.
-
-| ID | Tarea | Área | Depende de | Estado | Commit |
-|---|---|---|---|---|---|
-| MED-01 | Medir mezcla de tipos y complementos en el restore de prod | medicion | — | hecho | fiscalapi-ai@4f6664c |
-| MED-02 | Medir embudo y tiempo al primer timbre en el restore de Test | medicion | — | hecho | fiscalapi-ai@a5f9586 |
-| BE-01 | Publicar la superficie de 100 acciones con `[PublicApi]` | backend | — | hecho | fapi-back@8d31a9aa |
-| BE-02 | Agregar `operationId` estable y metadatos del documento | backend | BE-01 | hecho | fapi-back@8194141e |
-| BE-03 | Servir `/api/openapi/v4.json` anónimo en test y live | backend | BE-02 | hecho | fapi-back@3ab2a369 |
-| BE-04 | Agregar el trinquete de `EndpointSummary` y cubrir facturas | backend | BE-01 | hecho | fapi-back@2bd55554 |
-| BE-05 | Completar `EndpointSummary` en el resto de la superficie | backend | BE-04 | hecho | fapi-back@bc22dcec |
-| DOC-01 | Generar `.md` por sección H2 en el build | docs | — | hecho | fapi-docs@b97ad82 |
-| DOC-02 | Generar la página de errores de Pagos | docs | — | hecho | fapi-docs@816f3a4 |
-| DOC-03 | Corregir el enlace del SDK de Node en `llms.txt` | docs | — | hecho | fapi-docs@b5eec29 |
-| DOC-04 | Generar la página de errores de Comercio Exterior | docs | — | hecho | fapi-docs@73d326f, fapi-reusables@3be8d36 |
-| SK-01 | Armar el esqueleto del repo, los manifiestos y el CI de enlaces | skills | — | hecho | fiscalapi-ai@6caa2c4 |
-| SK-10 | Validar en el check semanal los enlaces "Docs:" del OpenAPI | skills | SK-01, BE-05 | hecho | fiscalapi-ai@b38786b |
-| SK-02 | Escribir `fiscalapi-integracion` y `primer-timbre` | skills | SK-01, DOC-01 | hecho | fiscalapi-ai@05135bf |
-| SK-03 | Escribir `fiscalapi-diagnostico` y `explicar-error` | skills | SK-01, DOC-02 | hecho | fiscalapi-ai@bce6b59 |
-| SK-04 | Escribir `fiscalapi-ingreso-egreso` | skills | SK-02, MED-01 | hecho | fiscalapi-ai@1bcaf7d |
-| SK-05 | Escribir `fiscalapi-pagos` | skills | SK-02, MED-01 | hecho | fiscalapi-ai@557c1f6 |
-| SK-06 | Escribir `fiscalapi-ciclo-de-vida` y `revisar-integracion` | skills | SK-02 | hecho | fiscalapi-ai@e5d6690 |
-| SK-07 | Escribir `fiscalapi-nomina` | skills | SK-02, MED-01 | hecho | fiscalapi-ai@51cbb20 |
-| SK-08 | Escribir `fiscalapi-carta-porte` | skills | SK-02, MED-01 | hecho | fiscalapi-ai@9849def |
-| SK-09 | Escribir `fiscalapi-comercio-exterior` | skills | SK-02, MED-01 | hecho | fiscalapi-ai@8cb8c77 |
-| MCP-01 | Crear el paquete stdio con `listar_operaciones` y `obtener_operacion` | mcp | SK-01, BE-03 | hecho | fiscalapi-ai@6e0c755 |
-| MCP-02 | Agregar `buscar_docs` con índice en español | mcp | MCP-01, DOC-01 | hecho | fiscalapi-ai@3594080 |
-| EV-01 | Montar el harness de evals con los casos de ingreso y pagos | evals | SK-05, MCP-02 | pendiente | |
-| EV-02 | Agregar casos de nómina, carta porte y comercio exterior | evals | EV-01, SK-09 | pendiente | |
-| EV-03 | Correr las 3 variantes y reportar | evals | EV-02 | pendiente | |
-| U-02 | Confirmar el nombre y el scope del paquete npm (D-14) | usuario | — | hecho | |
-| MCP-03 | Agregar el workflow de build y publicación por hash | mcp | MCP-02, U-02 | pendiente | |
-| U-03 | Importar `/api/openapi/v4.json` al workspace de Postman | usuario | BE-03 | pendiente | |
-| U-04 | Decidir la publicación en npm y en los marketplaces con el reporte de EV-03 | usuario | EV-03, MCP-03 | pendiente | |
-| CIERRE | Suite de fapi-back, instalar en los 4 hosts y borrador de notas | cierre | todas | pendiente | |
diff --git a/docs/ai-integracion/backend.md b/docs/ai-integracion/backend.md
deleted file mode 100644
index 97bd7ff..0000000
--- a/docs/ai-integracion/backend.md
+++ /dev/null
@@ -1,115 +0,0 @@
-# Integración asistida por IA: backend (fapi-back)
-
-El código de estas tareas va en fapi-back, en la rama `feat/ai-integracion` desde `main`. La fila del tablero se cierra con `fapi-back@`. Antes de usar cualquier API de `Microsoft.AspNetCore.OpenApi`, se consulta la documentación oficial de ASP.NET Core 10 (MCP microsoft-learn), sin responder de memoria.
-
-### BE-01 — Publicar la superficie de 100 acciones con `[PublicApi]`
-**Objetivo:** que el documento `v4` contenga exactamente las 100 acciones de D-05 y nada más, protegido por una prueba de equivalencia con la colección.
-**Depende de:** —
-**Skills:** `fapi-new-feature`, `dotnet-aspnetcore:dotnet-webapi`, `dotnet-test:run-tests`, `fapi-e2e-validate`.
-**Modelo sugerido (opcional):** opus. Toca qué se expone públicamente (seguridad).
-**Contexto:** D-05, D-12, RF-01, RNF-03. Hoy el documento se registra en `Infrastructure/DependencyInjection` con un `BuildServiceProvider` intermedio y un documento por versión (incluido el grupo v5 de `Controllers/V5/ValuesController`).
-**Alcance:**
-- Atributo `PublicApiAttribute` (metadata de endpoint) en las 100 acciones. Se aplica por acción, nunca por controller: en los mismos controllers hay 52 acciones fuera de la colección.
-- Registro explícito del documento `v4` con `ShouldInclude` = tiene `PublicApiAttribute`. Se quita el `BuildServiceProvider` intermedio. Si se conserva un documento completo para Scalar en Development, se decide aquí y se documenta como D-NN.
-- Prueba de equivalencia en WebApi.Tests:
- - lee los `*.request.yaml` de `postman/collections/Fiscalapi API Reference`;
- - normaliza MÉTODO+ruta (ids y valores → `{param}`; pdf y xml del portal → `{format}`);
- - resta la lista explícita de las 6 exclusiones de D-05, con su motivo en el código;
- - exige igualdad con las operaciones del documento `v4`.
-
- El parser YAML lo decide la tarea: se reutiliza uno de la solución si existe.
-**Consumidores afectados:** Scalar en Development (deja de ver lo no público si no se conserva un documento completo); `grep -rn "AddOpenApi\|MapOpenApi\|MapScalarApiReference" WebApi.*`.
-**Criterios de aceptación (todos con `→ verificar`):**
-- El documento local trae 100 operaciones → verificar: `curl -s http://localhost:5000/api/openapi/v4.json | jq '[.paths[]|keys[]]|length'`, una vez que BE-03 mapee la ruta; antes, desde la prueba.
-- Nada interno aparece (Tenants, TestAccounts, Checkouts, Impersonation, register, login, `load`) → verificar: la prueba de equivalencia, más `jq '.paths|keys'` revisado a mano.
-- La prueba falla si se quita un `[PublicApi]` o una exclusión → verificar: quitar uno, correr la prueba y verla en rojo; luego restaurar.
-**Pruebas focalizadas:** sí, por contrato y exposición. `dotnet test WebApi.Tests/WebApi.Tests.csproj --filter "FullyQualifiedName~OpenApi"`.
-**Fuera de alcance:** `operationId` y metadatos (BE-02); servir la ruta (BE-03); resúmenes (BE-04 y BE-05).
-**Notas:**
-- fapi-back está en el worktree `fapi-back-ai` (rama `feat/ai-integracion`): el working tree `fapi-back` lo ocupa `feat/localization`. Un solo documento `v4`, también para Scalar (D-17).
-- `PublicApiSurfaceTests` resuelve cada request contra todas las acciones v4 (la más literal gana): `tickets/{id}` no tiene `:guid`, así que comparar solo contra el documento no distinguía `tickets/load`.
-- Pendiente de la revisión, fuera de alcance: RNF-03 depende de la colección (no hay prueba propia de esquemas de auth), y las anotaciones OpenAPI de acciones internas ya no llegan a ningún documento.
-
-### BE-02 — Agregar `operationId` estable y metadatos del documento
-**Objetivo:** que cada operación tenga un `operationId` estable y que el documento declare servidores reales, autenticación y descripción.
-**Depende de:** BE-01.
-**Skills:** `dotnet-aspnetcore:dotnet-webapi`, `dotnet-test:run-tests`.
-**Modelo sugerido (opcional):** opus.
-**Contexto:** D-06, D-13, RF-01.
-**Alcance:**
-- `operationId` en las 100 acciones con el mecanismo de nombre de endpoint de ASP.NET Core 10, en camelCase y en inglés (`createInvoice`, `cancelInvoice`, …). La convención queda en D-13 al cerrar.
-- Transformer de documento:
- - `info` con título, versión de la API y la `description` tomada del spec de Postman (`postman/specs/Fiscalapi Specification`, solo como fuente del texto);
- - `servers` con `https://test.fiscalapi.com` y `https://live.fiscalapi.com`;
- - esquemas de seguridad `apiKey` en header para `X-API-KEY` y `X-TENANT-KEY`, aplicados a las operaciones no anónimas;
- - parámetro de header opcional `X-TIME-ZONE`.
-- Prueba: los `operationId` son únicos y no vacíos, y las operaciones de `portal/*` no exigen seguridad.
-**Textos visibles (propuesta al usuario):** el `info.description` final; se le muestra al usuario antes del commit (contrato público).
-**Criterios de aceptación (todos con `→ verificar`):**
-- 100 `operationId` únicos → verificar: `jq '[.paths[][]|.operationId]|unique|length'` igual a 100.
-- Hay servidores y esquemas de seguridad → verificar: `jq '.servers, .components.securitySchemes'`.
-- El documento pasa un lint de OpenAPI 3.1 → verificar: `npx @redocly/cli@ lint` sobre el JSON descargado.
-**Pruebas focalizadas:** sí (contrato). `--filter "FullyQualifiedName~OpenApi"`.
-**Fuera de alcance:** resúmenes por operación (BE-04 y BE-05).
-**Notas:**
-- **Textos visibles** aprobados por el usuario (2026-10-15): `title` "Fiscalapi API", `version` "4.0" y como `description` el texto del spec de Postman (párrafo de la API, "Modos de operación" y los enlaces a credenciales y datos de prueba) sin la sección "Configura un environment", más la línea "Ambientes: `https://test.fiscalapi.com` (pruebas) y `https://live.fiscalapi.com` (producción)".
-- La fuente (`postman/specs/Fiscalapi Specification`) solo existe sin rastrear en el working tree `fapi-back`, no en `main`: el texto aprobado se copia al transformer.
-- Hecho: el operationId va en `[PublicApi("…")]` (D-13). `X-TIME-ZONE` lleva una descripción redactada desde `TimezoneMiddleware` (texto visible asumido; se revisa con BE-04). Redocly 2.57.0: válido salvo `operation-summary` (BE-04/05).
-- Pendiente opcional: ninguna prueba fija los 100 nombres; renombrar uno no falla. La descripción aprobada dice que toda petición usa los dos headers, pero el portal es anónimo.
-
-### BE-03 — Servir `/api/openapi/v4.json` anónimo en test y live
-**Objetivo:** que cualquier agente o herramienta descargue el contrato sin credenciales y sin tocar nginx.
-**Depende de:** BE-02.
-**Skills:** `dotnet-aspnetcore:dotnet-webapi`, `fapi-release` (verifica que no haya pasos manuales de deploy), `fapi-e2e-validate`.
-**Modelo sugerido (opcional):** opus. Expone una ruta anónima en producción.
-**Contexto:** D-16. Hoy `MapOpenApi` y Scalar solo se registran en Development (`WebConfigurePipeline`).
-**Alcance:**
-- `MapOpenApi("/api/openapi/{documentName}.json")` en todos los ambientes, anónimo y con caché HTTP. Scalar sigue solo en Development.
-- Verificar que el middleware de autenticación y de tenant (`X-TENANT-KEY` obligatorio) no rechaza esta ruta.
-**Criterios de aceptación (todos con `→ verificar`):**
-- Responde 200 sin headers de auth en local con `ASPNETCORE_ENVIRONMENT=Test` → verificar: `curl -si http://localhost:5000/api/openapi/v4.json | head -1`.
-- No existe ruta para el documento v5 ni para uno interno → verificar: `curl -si …/api/openapi/v5.json` devuelve 404.
-- Tras el deploy del usuario, responde en test y live → verificar: `curl -si https://test.fiscalapi.com/api/openapi/v4.json` (en CIERRE).
-**Pruebas focalizadas:** sí (exposición). Prueba de integración: la ruta responde sin auth y no expone un documento distinto de `v4`.
-**Fuera de alcance:** importar a Postman (U-03).
-**Notas:**
-- Verificado con el host de pruebas (ambiente Test) y con `curl` contra la app en Development: no se levantó la app local en Test porque ahí se activan los jobs del SAT sobre el restore de prod. "Caché" es output cache del servidor (10 min); la respuesta no lleva `Cache-Control`.
-- Docs con la ruta vieja `/openapi/v4.json` "solo en Development" (prosa, nada lo consume en runtime): `fapi-back/docs/auto-invoice/{backend-emision,portal,release-notes}.md` y `fapi-auto-invoice/CLAUDE.md`.
-
-### BE-04 — Agregar el trinquete de `EndpointSummary` y cubrir facturas
-**Objetivo:** que ninguna operación pública nueva quede sin resumen, empezando por la ruta crítica del integrador.
-**Depende de:** BE-01.
-**Skills:** `dotnet-aspnetcore:dotnet-webapi`, `dotnet-test:run-tests`, `postman:api-documentation` y `postman:ai-readiness` (para redactar resúmenes y descripciones que contextualicen el OpenAPI generado).
-**Modelo sugerido (opcional):** haiku para escribir los resúmenes; opus revisa la prueba.
-**Contexto:** RF-01. Cada resumen es una línea en español más una `EndpointDescription` con el enlace a la sección de docs. No se ingieren comentarios XML: los `///` son notas internas. Indicación del usuario (2026-10-14): las descripciones deben usar el vocabulario fiscal con que busca un integrador, para que las consultas que fallaron en la prueba de búsqueda (`requisitos.md` → Contexto medido: "complemento de pago", "crear cliente receptor", "estatus ante el SAT", "descarga masiva") encuentren su operación.
-**Alcance:**
-- Prueba trinquete: lista las operaciones públicas sin `EndpointSummary` y la compara con una lista permitida en el código que solo puede bajar.
-- Resúmenes de Invoices, People y TaxFiles.
-**Textos visibles (propuesta al usuario):** los resúmenes de esta tarea; el usuario los revisa en el diff antes del commit.
-**Criterios de aceptación (todos con `→ verificar`):**
-- Agregar un `[PublicApi]` sin resumen hace fallar la prueba → verificar: agregarlo de forma temporal y ver la prueba en rojo.
-- Invoices, People y TaxFiles salen de la lista permitida → verificar: `jq '[.paths[][]|select(.summary==null)]|length'` baja en ese número.
-**Pruebas focalizadas:** sí (contrato). `--filter "FullyQualifiedName~OpenApi"`.
-**Fuera de alcance:** el resto de controllers (BE-05).
-**Notas:**
-- La lista permitida es `OperationsWithoutSummary` en `WebApi.Tests/OpenApi/PublicApiSummaryTests.cs` (41 `operationId`). BE-05 borra filas y al final borra la lista.
-- Las descripciones terminan en una línea "Docs:" que se arma con `PublicApiDocument.DocsUrl` y el ancla H2 real. Antes de usar cada ancla, compruébala contra docs.fiscalapi.com.
-- El usuario delegó la aprobación de los textos (2026-10-15, "analízalo tú y decide"); ver D-19. Que `details` de `getPerson` no tenga efecto quedó en `known-issues.md` de fapi-back.
-
-### BE-05 — Completar `EndpointSummary` en el resto de la superficie
-**Objetivo:** que la lista permitida del trinquete quede vacía.
-**Depende de:** BE-04.
-**Skills:** `dotnet-aspnetcore:dotnet-webapi`, `dotnet-test:run-tests`, `postman:api-documentation` y `postman:ai-readiness`.
-**Modelo sugerido (opcional):** haiku.
-**Contexto:** igual que BE-04, incluida la indicación del usuario sobre el vocabulario fiscal de las descripciones.
-**Alcance:** resúmenes y descripciones con enlace de Catalogs, Products, Stamps, ApiKeys, DownloadCatalogs, DownloadRequests, DownloadRules, Manifests, SatValidations, Tickets, Portal, AutoInvoice, Webhooks y Events (los que falten); la lista permitida se borra.
-**Textos visibles (propuesta al usuario):** los resúmenes de esta tarea, revisados en el diff.
-**Criterios de aceptación (todos con `→ verificar`):**
-- No hay operaciones públicas sin resumen → verificar: `jq '[.paths[][]|select(.summary==null)]|length'` igual a 0.
-- La lista permitida ya no existe → verificar: grep del nombre de la lista en WebApi.Tests sin resultados.
-**Pruebas focalizadas:** las mismas de BE-04.
-**Fuera de alcance:** —
-**Notas:**
-- Ya no hay operaciones sin resumen (41 → 0) y `PublicApiSummaryTests` lo exige sin lista. Los textos los decidió el agente por delegación del usuario (D-19). Las 31 operaciones que ya tenían resumen (Webhooks, Tickets, Portal, AutoInvoice, Events) siguen sin enlace "Docs:".
-- En Test (`FakeResults=true`), `runSatValidations` simula todos los tipos, aunque el comentario de `RunSatValidationsCommand.cs:161` diga que solo `sat.cfdi.status`.
-- Dos defectos nuevos quedaron en `known-issues.md` de fapi-back, los dos de descarga masiva: el SAT puede no aceptar una solicitud y aun así se guarda, y crear o borrar solicitudes solo exige `XmlDownloader.Read`.
diff --git a/docs/ai-integracion/decisiones.md b/docs/ai-integracion/decisiones.md
deleted file mode 100644
index 0c50306..0000000
--- a/docs/ai-integracion/decisiones.md
+++ /dev/null
@@ -1,140 +0,0 @@
-# Integración asistida por IA: decisiones vigentes
-
-Reglas de este archivo:
-- Cada decisión describe el estado actual. Si cambia, se reescribe en su lugar; la historia vive en git.
-- Estado (uno de tres):
- - `confirmada`: el usuario la aprobó o es técnica ya resuelta.
- - `propuesta`: requiere el OK del usuario antes de la tarea que la usa.
- - `técnica — la fija `: la decide el agente en esa tarea.
-- Una decisión nueva se agrega al final, con el siguiente D-NN libre.
-- Vocabulario del feature:
- - **superficie pública:** las acciones con `[PublicApi]`.
- - **catálogo de operaciones:** la lista compacta operationId + método + ruta + resumen.
- - **sección:** un bloque H2 de una página de docs con su propia `.md`.
- - **variante de eval:** sin nada, con skills, o con skills más MCP.
-
----
-
-### D-01 — La audiencia son los desarrolladores que integran
-- **Decisión:** los recursos van dirigidos a equipos que meten FiscalAPI en su ERP, POS o SaaS desde agentes de código (Claude Code, Codex, Cursor, Copilot). No a contadores ni pymes que facturan desde un chat.
-- **Por qué:** es donde se reduce el tiempo de integración, y en test no hay riesgo fiscal.
-- **Consecuencias:** nada de acciones en live; las skills enseñan a escribir el código de integración.
-- **Estado:** confirmada.
-
-### D-02 — Cero mantenimiento adicional y nada hosteado
-- **Decisión:** no se crea ningún servicio en ejecución (ni MCP remoto, ni BD, ni modelos). Todo lo derivado se genera solo desde el código o las docs.
-- **Por qué:** es una restricción dura del usuario: "No quiero agregar superficie de mantenimiento adicional a fiscalapi".
-- **Consecuencias:** se descarta el MCP remoto; el MCP stdio lleva el índice dentro del paquete y se regenera en CI (D-07, D-14).
-- **Estado:** confirmada.
-
-### D-03 — Las skills enrutan y enlazan; no copian las docs
-- **Decisión:** 8 skills (`fiscalapi-integracion`, `-ingreso-egreso`, `-pagos`, `-nomina`, `-carta-porte`, `-comercio-exterior`, `-ciclo-de-vida`, `-diagnostico`) y 3 comandos (`primer-timbre`, `explicar-error`, `revisar-integracion`). Siguen el estándar agentskills.io. Sus `references/` solo tienen índices de enlaces a las `.md` y árboles de decisión.
-- **Por qué:** si copiaran contenido de las docs, cada cambio de API obligaría a editarlas en paralelo (D-02).
-- **Consecuencias:** una skill solo cambia cuando cambia "cómo se decide"; un enlace roto lo detecta el CI de SK-01.
-- **Estado:** confirmada.
-
-### D-04 — Todo en español
-- **Decisión:** las skills, las herramientas y descripciones del MCP y las docs de este feature van en español.
-- **Por qué:** docs.fiscalapi.com y el vocabulario del SAT están en español.
-- **Consecuencias:** las consultas de regresión y las evals se escriben en español. Si la localización de fapi-docs se fusiona, se revisa si `buscar_docs` debe indexar también el inglés.
-- **Estado:** confirmada.
-
-### D-05 — OpenAPI desde el código; la superficie es la colección menos 6
-- **Decisión:** `/api/openapi/v4.json` se genera del código. Incluye solo las acciones con `[PublicApi]`: las 106 de la colección "Fiscalapi API Reference" menos `POST account/register`, `POST account/login`, `GET reports/params/{documentType}/{tin}/{invoiceType}`, `GET tickets/load`, `GET webhooks/load` y `GET webhooks/{id}/deliveries/load`. En total, 100. `portal/*` y `events/trigger` sí quedan públicos.
-- **Por qué:** lo indicó el usuario. La colección ya es la referencia pública validada.
-- **Consecuencias:** una prueba de equivalencia lee la colección, resta esas 6 exclusiones y exige igualdad con el documento. Que `portal/*` y `events/trigger` sigan públicos es interpretación del agente (el usuario no los excluyó); el usuario puede corregirla.
-- **Estado:** confirmada.
-
-### D-06 — Un solo contrato; la colección conserva su propósito
-- **Decisión:** el spec "Fiscalapi Specification" del workspace de Postman se reemplaza importando `/api/openapi/v4.json` (U-03) y deja de generarse desde la colección. La colección sigue siendo el artefacto de ejemplos curados por tipo de CFDI.
-- **Por qué:** dos contratos en paralelo serían mantenimiento extra. El spec generado desde la colección no tiene esquemas ni `operationId`.
-- **Consecuencias:** el `info.description` del spec de Postman pasa al transformer de BE-02. El archivo local `postman/specs/` queda obsoleto.
-- **Estado:** confirmada.
-
-### D-07 — MCP stdio de solo lectura con 3 herramientas
-- **Decisión:** `@fiscalapi/mcp` expone `listar_operaciones(tag?)`, `obtener_operacion(operationId)` (con los `$ref` resueltos y recortado) y `buscar_docs(consulta)`. No tiene `invocar`, y no usa red en runtime.
-- **Por qué:** la llamada a la API debe vivir en el código del integrador. Invocar desde el MCP no adelanta la integración y agrega superficie de seguridad.
-- **Consecuencias:** el OpenAPI y el índice van dentro del paquete. Se publica solo si EV-03 muestra mejora frente a las skills solas (U-04).
-- **Estado:** confirmada.
-
-### D-08 — Se busca en las docs, no en el OpenAPI, y sin embeddings
-- **Decisión:** `listar_operaciones` devuelve el catálogo completo, sin búsqueda. `buscar_docs` usa MiniSearch sobre las secciones, con un `processTerm` en español: minúsculas, sin acentos, stopwords de `@orama/stopwords` y stemmer de `@orama/stemmers`.
-- **Por qué:** medido (ver `requisitos.md`). Sobre las operaciones, todos los motores fallan en lo fiscal; sobre las secciones de las docs, el léxico acierta 14 de 16. Los embeddings cuestan 130 MB y 14 s de arranque sin ganar nada.
-- **Consecuencias:** las 16 consultas de la medición son la prueba de regresión de MCP-02.
-- **Estado:** confirmada.
-
-### D-09 — El sandbox sin "compra" se pospone
-- **Decisión:** no se toca billing en este feature. La skill `fiscalapi-integracion` guía por el flujo actual (cuenta, suscripción de prueba con tarjeta de prueba y llaves).
-- **Por qué:** sería código nuevo en un área crítica. El usuario pidió medir primero.
-- **Consecuencias:** MED-02 mide el abandono en el paso de compra y el resultado se lleva al usuario.
-- **Estado:** confirmada.
-
-### D-10 — Los CSD de prueba se siguen enlazando desde SW
-- **Decisión:** no se alojan certificados de prueba propios.
-- **Por qué:** alojarlos obliga a renovarlos cuando venzan (D-02).
-- **Consecuencias:** las skills enlazan a la página `testing-data` de las docs.
-- **Estado:** confirmada.
-
-### D-11 — Evals manuales antes de cada release, en 3 variantes
-- **Decisión:** cada caso CFDI se corre sin nada, con skills, y con skills más MCP. Se califica con un UUID real en test más la consulta de su estado, y se cuentan los tokens. Se corre a mano antes de publicar, no en CI nocturno.
-- **Por qué:** se mide la mejora sin un costo fijo de tokens.
-- **Consecuencias:** EV-01 decide si reutilizar `skill-validator` de dotnet/skills o escribir un script mínimo.
-- **Estado:** técnica — la fija EV-01.
-
-### D-12 — El feature vive en fiscalapi-ai
-- **Decisión:** el tablero y todo el trabajo de skills, MCP y evals van en `FiscalAPI/fiscalapi-ai` (público), en la rama `feat/ai-integracion` desde `main`. fapi-back y fapi-docs solo reciben los cambios mínimos de BE-* y DOC-*, cada uno en su rama.
-- **Por qué:** lo indicó el usuario: "solo escribe sobre fiscalapi-ai".
-- **Consecuencias:** las filas BE-* y DOC-* se cierran con `@`.
-- **Estado:** confirmada.
-
-### D-13 — `operationId` estable por acción pública
-- **Decisión:** cada acción con `[PublicApi]` declara un `operationId` estable, en camelCase y en inglés como el código. Va como argumento obligatorio del mismo atributo (`[PublicApi("createInvoice")]`), que implementa `IEndpointNameMetadata`: el generador de ASP.NET Core 10 toma el operationId del nombre de ruta o, si no hay, de ese metadato (`OpenApiDocumentService.GetOperationId`, release/10.0). Convención: `list` para colecciones, `get` / `create` / `update` / `delete` para el CRUD y el verbo del dominio cuando lo hay (`cancelInvoice`, `revokeApiKey`, `cancelTicket`, `transferStamps`, `runSatValidations`).
-- **Por qué:** el MCP y las skills lo usan como id. El spec de Postman no trae ninguno.
-- **Consecuencias:** cambiar un `operationId` es un cambio visible para los consumidores del OpenAPI.
-- **Estado:** confirmada (técnica, la fijó BE-02).
-
-### D-14 — Paquete `@fiscalapi/mcp`, publicado por CI cuando cambia el hash
-- **Decisión:**
- - Nombre `@fiscalapi/mcp`, publicado con `access: public` por el usuario npm `fiscalapi`.
- - Node ≥22 y `@modelcontextprotocol/server` v2 con versión fijada, más Dependabot.
- - El build descarga `/api/openapi/v4.json` de live y las `.md` de sección, y publica solo si cambia el hash.
- - Publica con trusted publishing desde un runner hospedado por GitHub.
-- **Por qué:** el usuario confirmó el nombre (2026-10-14), que está libre en npm. Trusted publishing no acepta runners self-hosted.
-- **Consecuencias:** MCP-03 verifica en la documentación de npm si la primera publicación puede ser por OIDC o si requiere una publicación manual del usuario.
-- **Estado:** confirmada.
-
-### D-15 — Sin `llms-full.txt`
-- **Decisión:** no se genera `llms-full.txt`. Lo que se agrega es la `.md` por sección.
-- **Por qué:** serían unos 600k tokens, inservibles para un agente; una sola sección pesa hasta 156 KB.
-- **Consecuencias:** DOC-01 genera las secciones y `llms.txt` sigue siendo el índice.
-- **Estado:** confirmada.
-
-### D-16 — El OpenAPI se sirve bajo `/api` para no tocar nginx
-- **Decisión:** `MapOpenApi` se mapea a `/api/openapi/{documentName}.json`, anónimo y en todos los ambientes.
-- **Por qué:** el nginx de test y el de live ya mandan `location ^~ /api` al back. El CD no toca nginx, que se cambia a mano (`infra/nginx/live/README.md`). Con otra ruta habría un paso manual.
-- **Consecuencias:** cero cambios en nginx. `/api` no tiene `limit_req` en nginx y el limiter de la app solo cubre familias marcadas, así que la ruta no tiene rate limit: el costo lo acota una output cache de una sola entrada (no varía por query, host ni `Authorization`). Corregido en BE-03; antes decía que heredaba el rate limiting de `/api`.
-- **Estado:** confirmada.
-
-### D-17 — Un solo documento OpenAPI: el público
-- **Decisión:** fapi-back registra un solo documento, `v4`, con las acciones `[PublicApi]`. Scalar en Development muestra ese mismo documento; no se conserva un documento completo con lo interno, y el grupo v5 (`Controllers/V5/ValuesController`) se queda sin documento.
-- **Por qué:** lo interno (dashboard, Host, DevExtreme) no es contrato, y un segundo documento sería configuración que nadie consume. Así Scalar muestra lo mismo que reciben los integradores.
-- **Consecuencias:** las pruebas de contrato OpenAPI solo cubren operaciones públicas. Las rutas internas se siguen probando por HTTP.
-- **Estado:** confirmada (técnica, la fijó BE-01).
-
-### D-18 — Manifiestos: `.claude-plugin/` para tres hosts y una copia para Cursor
-- **Decisión:** el plugin `fiscalapi` (marketplace `fiscalapi`, `source: "./"`) se declara en `.claude-plugin/plugin.json` y `.claude-plugin/marketplace.json`, sin campo `skills` (cada host descubre `skills//SKILL.md`). Cursor lee `.cursor-plugin/plugin.json`, que es una copia exacta. No se crean `.codex-plugin/` ni `.github/plugin/`.
-- **Por qué:** Codex lee `.claude-plugin/marketplace.json` y `.claude-plugin/plugin.json` (`MARKETPLACE_MANIFEST_RELATIVE_PATHS` y `DISCOVERABLE_PLUGIN_MANIFEST_PATHS` en `openai/codex`), y Copilot los busca en su orden documentado. Un manifiesto por host sería mantenimiento en paralelo (D-02). El campo `skills` se suma al descubrimiento en Claude, lo reemplaza en Cursor y está prohibido en Agent Plugins 1.0.
-- **Consecuencias:** el CI valida con `claude plugin validate . --strict` y exige que la copia de Cursor sea idéntica (`cmp`). El MCP no se declara hasta U-04 (D-07).
-- **Estado:** confirmada (técnica, la fijó SK-01).
-
-### D-19 — Resúmenes OpenAPI con el vocabulario de búsqueda del integrador
-- **Decisión:** el `EndpointSummary` y la `EndpointDescription` de cada operación pública usan los términos con que busca un integrador ("complemento de pago", "cliente receptor", "estatus ante el SAT", "patrón"), aunque el vocabulario interno de fapi-core evite "cliente" para una persona. Cada descripción termina con una línea "Docs:" que enlaza la sección H2 de docs.fiscalapi.com que la documenta.
-- **Por qué:** se buscan para que las consultas que fallaron en la prueba de búsqueda (`requisitos.md`) encuentren su operación, como pidió el usuario el 2026-10-14. El vocabulario interno gobierna el código y la prosa del equipo, no las palabras con que busca un integrador.
-- **Consecuencias:** BE-05 sigue la misma regla. Un texto que afirme un código HTTP o un campo se valida contra el handler.
-- **Estado:** asumida (agente, 2026-10-15). El usuario delegó la aprobación de los textos de BE-04 ("analízalo tú y decide"); revisar con el usuario.
-
-### D-20 — El top de rechazos del PAC queda fuera de alcance
-- **Decisión:** no se mide el top de rechazos del PAC en el Seq de producción (MED-03), y no se abre el túnel a Seq (U-01). Las dos filas salen del tablero.
-- **Por qué:** lo decidió el usuario el 2026-10-15. Las skills ya están escritas sin ese dato, y ninguna otra tarea depende de MED-03.
-- **Consecuencias:** EV-01 usa un código representativo de cada matriz (`CFDI40`, `CRP`, `NOM`, `CP`, `CCE`) para los casos de "explicar error". `fiscalapi-diagnostico` cubre todas las familias, aunque sin ordenarlas por frecuencia real.
-- **Estado:** confirmada.
diff --git a/docs/ai-integracion/docs.md b/docs/ai-integracion/docs.md
deleted file mode 100644
index f41fb8b..0000000
--- a/docs/ai-integracion/docs.md
+++ /dev/null
@@ -1,77 +0,0 @@
-# Integración asistida por IA: docs (fapi-docs y fapi-reusables)
-
-El código va en fapi-docs (y en fapi-reusables solo si cambia el generador), en la rama `feat/ai-integracion` desde `main`. La fila del tablero se cierra con `@`.
-
-**Hecho al planear:** fapi-docs tenía en curso la rama `feat/localization`. En `main` hay 0 archivos `content.en.mdx` y 52 `content.mdx`. Antes de DOC-01, revisa si la localización ya llegó a `main`; si llegó, la generación por sección cubre también las páginas en inglés y se carga `fapi-i18n`.
-
-### DOC-01 — Generar `.md` por sección H2 en el build
-**Objetivo:** que un agente pueda leer una sola sección (por ejemplo "Crear factura de pago") sin descargar la página completa, que llega a 647 KB.
-**Depende de:** —
-**Skills:** `fapi-sdk-docs-sync` (docs públicas), `fapi-i18n` (solo si la localización ya está en `main`).
-**Modelo sugerido (opcional):** opus. Cambia rutas públicas del sitio.
-**Contexto:** D-15, RF-02. Hoy `src/app/md/[[...slug]]/route.js` y los rewrites de `next.config.mjs` sirven `.md`, y `getPageMarkdown` vive en `src/lib/docs.js`.
-**Alcance:**
-- Ruta de sección `/
/.md`, con el mismo slug que el ancla H2 que ya genera el sitio. Devuelve el markdown de esa sección, del H2 hasta el siguiente H2.
-- Se genera desde los mismos MDX en build (prerender), sin contenido nuevo que mantener.
-- `llms.txt` agrega, por página, la lista de sus secciones con su URL `.md`.
-**Criterios de aceptación (todos con `→ verificar`):**
-- La sección se sirve sola → verificar: `npm run build && npm start` y `curl -s localhost:3000/invoices-by-value/crear-factura-de-pago.md | head`; el contenido empieza en ese H2 y no incluye el siguiente.
-- Cada ancla del sitio tiene su `.md` → verificar: un script de build cuenta las secciones de los MDX (427 al planear) y las rutas generadas, y las compara.
-- `llms.txt` lista las secciones → verificar: `curl -s localhost:3000/llms.txt | grep -c "\.md"` sube respecto a `main`.
-**Pruebas focalizadas:** ninguna (no aplica). La verificación es el conteo del build.
-**Fuera de alcance:** `llms-full.txt` (D-15).
-**Notas:**
-- Hay 378 secciones H2 en 53 páginas y 429 copias `.md`. Las 427 del plan eran una estimación. La guarda `npm run check:markdown` corre como `postbuild` y compara contra las anclas del HTML construido. Los H2 de ``, `` y `` no tienen copia.
-- Las secciones de `invoices-by-value` y `invoices-by-reference` (`EXCLUDED_ROUTES`) tienen copia y van en `llms.txt` como `[Página: Sección]`, por decisión del usuario. Como robots no se tocó, su `Disallow` por prefijo también bloquea esas 46 copias para los crawlers que respetan robots.
-- Antes de fusionar, `es:diff` de localización (`feat/localization`) va a marcar `/llms.txt` y las `.md` nuevas: declara la diferencia en `scripts/i18n/es-allowed.json`.
-
-### DOC-02 — Generar la página de errores de Pagos
-**Objetivo:** que `fiscalapi-diagnostico` y `explicar-error` puedan enlazar los errores `CRP*`, igual que hoy enlazan los de CFDI 4.0, Nómina y Carta Porte. Comercio Exterior pasó a DOC-04.
-**Depende de:** —
-**Skills:** `fapi-cfdi-rules`, `fapi-sdk-docs-sync`.
-**Modelo sugerido (opcional):** haiku. Corre el generador existente.
-**Contexto:** RF-03. Las páginas actuales salen de `fapi-reusables/scripts/sat-docs-md-errors-generator/app.py` sobre las matrices de `fapi-reusables/sat-docs`. Ya existen `sat-docs/pagos` y `sat-docs/comercio-exterior`.
-**Alcance:**
-- Correr el generador con la matriz de Pagos 2.0 y crear la página `errors/pagos-20` con el mismo formato que `errors/nomina-12`.
-- Agregarlas a la navegación y a `llms.txt`.
-- Si el generador necesita un cambio, va en fapi-reusables en su propio commit.
-**Criterios de aceptación (todos con `→ verificar`):**
-- La página responde en `.md` → verificar: `curl -s localhost:3100/errors/pagos-20.md | grep -c "CRP"` mayor que 0.
-- El número de códigos coincide con la matriz del SAT → verificar: conteo de filas de la matriz contra el conteo en la página.
-**Pruebas focalizadas:** ninguna (no aplica).
-**Fuera de alcance:** traducir los mensajes del SAT.
-**Notas:**
-- Comercio Exterior pasó a DOC-04: `sat-docs/comercio-exterior` no trae su matriz de errores. Las páginas de errores actuales se editaron a mano después de generarse, así que `pagos-20` toma la estructura de `nomina-12` y solo la tabla sale del generador.
-- El generador se corrió con la salida en el scratchpad: fapi-reusables está en `feat/auto-invoice` con cambios propios. 103 códigos CRP en la matriz y en la página. El repo fapi-docs tiene `user.name` local "Fiscalapi": se fija el autor mendozagit al hacer commit.
-
-### DOC-03 — Corregir el enlace del SDK de Node en `llms.txt`
-**Objetivo:** que los agentes lleguen al repo real del SDK de Node.
-**Depende de:** —
-**Skills:** `fapi-sdk-docs-sync`.
-**Modelo sugerido (opcional):** haiku.
-**Contexto:** `src/app/llms.txt/route.js` enlaza `github.com/FiscalAPI/fiscalapi-js`, y el repo real es `fiscalapi-node`.
-**Alcance:** cambiar el enlace y hacer grep de `fiscalapi-js` en todo fapi-docs por si aparece en otro lugar.
-**Criterios de aceptación (todos con `→ verificar`):**
-- No queda ningún `fiscalapi-js` → verificar: `grep -rn "fiscalapi-js" src` sin resultados.
-- El enlace nuevo existe → verificar: `gh repo view FiscalAPI/fiscalapi-node --json name`.
-**Pruebas focalizadas:** ninguna (no aplica).
-**Fuera de alcance:** —
-**Notas:**
-- También estaba en `src/components/Libraries.jsx` (la tarjeta del SDK en el sitio). `fiscalapi-js` es el nombre anterior del repo y GitHub redirige; los otros 4 SDK resuelven con su nombre.
-
-### DOC-04 — Generar la página de errores de Comercio Exterior
-**Objetivo:** que `fiscalapi-diagnostico` y `explicar-error` puedan enlazar los errores `CCE*`, como DOC-02 hizo con los `CRP*`.
-**Depende de:** —
-**Skills:** `fapi-cfdi-rules`, `fapi-sdk-docs-sync`.
-**Modelo sugerido (opcional):** haiku. Corre el generador existente.
-**Contexto:** RF-03. Se separó de DOC-02: `fapi-reusables/sat-docs/comercio-exterior` no trae la matriz de errores (solo `Catalogos_Comercio_Exterior.xlsx` y las guías, sin ningún código `CCE###`). MED-01 midió 0 facturas de Comercio Exterior en producción.
-**Alcance:** agregar la matriz oficial del SAT a `sat-docs/comercio-exterior/catalogs` (commit propio en fapi-reusables) y crear `errors/comercio-exterior-20` como DOC-02 creó `errors/pagos-20`, con su entrada en la navegación y en la tabla de `errors`.
-**Criterios de aceptación (todos con `→ verificar`):**
-- La página responde en `.md` → verificar: `curl -s localhost:3100/errors/comercio-exterior-20.md | grep -c "CCE"` mayor que 0.
-- El número de códigos coincide con la matriz → verificar: conteo de códigos `CCE` únicos de la matriz contra los de la página.
-**Pruebas focalizadas:** ninguna (no aplica).
-**Fuera de alcance:** traducir los mensajes del SAT.
-**Notas:**
-- La matriz 2.0 Rev A quedó versionada en `fapi-reusables@3be8d36` (worktree `fapi-reusables-ai`). La copia sin rastrear del working tree del usuario sigue ahí y no se tocó.
-- La página toma la estructura de `pagos-20` y del generador solo sale la tabla. La columna Aclaraciones vino vacía en las 73 filas, así que la tabla tiene las mismas 6 columnas que Pagos.
-- Matriz y página tienen los mismos 73 códigos CCE, sin diferencias.
diff --git a/docs/ai-integracion/evals.md b/docs/ai-integracion/evals.md
deleted file mode 100644
index 6019ed5..0000000
--- a/docs/ai-integracion/evals.md
+++ /dev/null
@@ -1,61 +0,0 @@
-# Integración asistida por IA: evals
-
-Todo va en fiscalapi-ai, en `evals/`, en la rama `feat/ai-integracion`. Las evals se corren a mano antes de cada release (D-11), con una cuenta y una llave de test de FiscalAPI que se leen de variables de entorno y nunca se escriben en el repo.
-
-### EV-01 — Montar el harness de evals con los casos de ingreso y pagos
-**Objetivo:** medir, caso por caso, si un agente integra FiscalAPI mejor con las skills y con el MCP que sin nada.
-**Depende de:** SK-05, MCP-02.
-**Skills:** `anthropic-skills:skill-creator` (sección de evals), `superpowers:writing-skills`. El patrón de `skill-validator` de dotnet/skills se revisa con `gh api`.
-**Modelo sugerido (opcional):** opus. Diseña la medición de la que depende U-04.
-**Contexto:** D-11, RF-06.
-**Alcance:**
-- Elegir el runner: reutilizar `skill-validator` (dotnet/skills) o un script mínimo que lance el agente en modo no interactivo sobre un proyecto plantilla. Se decide aquí y se documenta en D-11.
-- Cada caso trae:
- - el prompt del integrador en español;
- - un proyecto plantilla (Node Express y .NET mínimo);
- - las 3 variantes de D-11.
-- Calificación automática: el proyecto resultante, ejecutado, produce un UUID en test, y `GET /api/v4/invoices/{id}` confirma su estado.
-- Métricas: éxito, tokens e intentos.
-- Casos iniciales: factura de ingreso, nota de crédito, factura global, y factura PPD con su complemento de pago.
-**Criterios de aceptación (todos con `→ verificar`):**
-- Un solo comando corre los 4 casos en las 3 variantes y genera un reporte en tabla → verificar: ejecución y reporte generado.
-- Un caso con una skill rota a propósito baja su tasa de éxito → verificar: corrida con un enlace o una regla falsa plantada.
-**Pruebas focalizadas:** ninguna (no aplica). El harness es la prueba.
-**Fuera de alcance:** correr en CI (D-11).
-**Notas:** pausada el 2026-10-15 por el usuario antes de empezar: sin código ni decisiones. Al retomar, D-11 sigue sin fijar el runner. MCP-02 quedó en `fiscalapi-ai@3594080`, y las docs locales de verificación se sirven con `npx next start -p 3999` en fapi-docs-ai.
-
-### EV-02 — Agregar casos de nómina, carta porte y comercio exterior
-**Objetivo:** cubrir con evals todos los casos CFDI del feature.
-**Depende de:** EV-01, SK-09.
-**Skills:** `fapi-cfdi-rules`.
-**Modelo sugerido (opcional):** haiku para escribir los casos; opus revisa la calificación.
-**Contexto:** D-03; el orden y los casos extra los fija MED-01; los códigos de "explicar error" son representativos de las matrices (D-20).
-**Alcance:**
-- Casos de nómina ordinaria, Carta Porte de traslado por autotransporte y exportación definitiva con Comercio Exterior.
-- Un caso de "explicar error" por familia de código: `CFDI40`, `CRP`, `NOM`, `CP` y `CCE`, uno representativo de su matriz en las docs.
-**Criterios de aceptación (todos con `→ verificar`):**
-- Los casos nuevos corren en las 3 variantes → verificar: reporte con las filas nuevas.
-**Pruebas focalizadas:** ninguna (no aplica).
-**Fuera de alcance:** —
-**Notas:**
-
-### EV-03 — Correr las 3 variantes y reportar
-**Objetivo:** dar al usuario los datos para U-04: publicar o no el MCP y pedir o no los marketplaces oficiales (Q-01).
-**Depende de:** EV-02.
-**Skills:** `superpowers:verification-before-completion`.
-**Modelo sugerido (opcional):** opus.
-**Contexto:** D-07, D-11, Q-01.
-**Alcance:**
-- Corrida completa con N repeticiones por caso (N lo fija EV-01 según la varianza).
-- Tabla de éxito, tokens e intentos por caso y variante.
-- Recomendación:
- - si las skills mejoran frente a sin nada;
- - si el MCP mejora frente a las skills solas;
- - qué skills reescribir.
-- El resumen va en las Notas de esta tarea y en el cuerpo del commit; no se versiona ningún archivo de evidencia.
-**Criterios de aceptación (todos con `→ verificar`):**
-- El reporte existe y cubre todos los casos y variantes → verificar: tabla en el cuerpo del commit.
-- La recomendación se apoya en las cifras → verificar: cada afirmación cita su fila.
-**Pruebas focalizadas:** ninguna (no aplica).
-**Fuera de alcance:** publicar (U-04).
-**Notas:**
diff --git a/docs/ai-integracion/mcp.md b/docs/ai-integracion/mcp.md
deleted file mode 100644
index 01e396e..0000000
--- a/docs/ai-integracion/mcp.md
+++ /dev/null
@@ -1,84 +0,0 @@
-# Integración asistida por IA: MCP stdio (`@fiscalapi/mcp`)
-
-Todo va en fiscalapi-ai, en `mcp/`, en la rama `feat/ai-integracion`.
-
-Antes de escribir código contra el SDK de MCP, se consulta su documentación vigente (context7 o el repo `modelcontextprotocol/typescript-sdk`). La spec cambió el 2026-07-28 y el SDK v2 se mueve rápido.
-
-### MCP-01 — Crear el paquete stdio con `listar_operaciones` y `obtener_operacion`
-**Objetivo:** que `npx` levante un servidor stdio que dé al agente el catálogo completo de operaciones y el esquema de una operación, sin usar red.
-**Depende de:** SK-01, BE-03.
-**Skills:** `context7-mcp` (SDK de MCP), `superpowers:test-driven-development`.
-**Modelo sugerido (opcional):** opus. Fija la base del paquete.
-**Contexto:** D-07, D-08, D-13, D-14, RF-05, RNF-05, RNF-06.
-**Alcance:**
-- Paquete `@fiscalapi/mcp`:
- - TypeScript y ESM;
- - `engines.node >=22`;
- - `@modelcontextprotocol/server` v2 con versión exacta;
- - `bin` con shebang;
- - `publishConfig.access: public`;
- - licencia igual a la de los SDK.
-- Script de build:
- - descarga `/api/openapi/v4.json` (live por omisión; ruta local para desarrollo);
- - genera `catalog.json` (operationId, método, ruta, resumen, tag) y `operations/.json` con los `$ref` resueltos y recortados a unos 10k tokens como máximo;
- - guarda el hash de la fuente.
-- Herramientas, con descripciones en español:
- - `listar_operaciones(tag?)` devuelve el catálogo compacto;
- - `obtener_operacion(operationId)` devuelve parámetros, cuerpo y respuestas;
- - un operationId inexistente devuelve un error claro con sugerencias por prefijo.
-- Pruebas con un cliente MCP en proceso: `tools/list`, las dos herramientas, el error de id inexistente y el límite de tamaño.
-**Criterios de aceptación (todos con `→ verificar`):**
-- Arranca sin red → verificar: con la red desactivada (o variable que bloquea `fetch`), `node dist/index.js` responde `tools/list`.
-- `listar_operaciones` devuelve 100 operaciones y cabe en unos 3k tokens como máximo → verificar: prueba que cuenta elementos y caracteres.
-- `obtener_operacion("createInvoice")` (el nombre que fije D-13) no pasa del límite → verificar: prueba de tamaño.
-- Funciona registrado en Claude Code → verificar: `claude mcp add fiscalapi -- node /dist/index.js` y una consulta en la sesión.
-**Pruebas focalizadas:** sí (contrato del paquete). `npm test` en `mcp/`.
-**Fuera de alcance:** búsqueda en docs (MCP-02); publicación (MCP-03); herramienta `invocar` (D-07).
-**Notas:**
-- `@modelcontextprotocol/server` 2.3.1 (v2: `McpServer.registerTool` y `serveStdio`, que atiende las eras del protocolo 2025 y 2026) y zod 4.6.7, con versiones exactas. `data/` no se versiona: lo genera `npm run build:data`, que lee live por omisión o `FISCALAPI_OPENAPI_SOURCE`. Las pruebas usan el fixture del OpenAPI de BE-05.
-- Con el fixture: 100 operaciones; el catálogo mide 9,023 caracteres y la operación mayor, createInvoice, 28,130 (se colapsan los media types duplicados de ASP.NET). Arranca en unos 200 ms sin red y pasan 123 pruebas. En Claude Code (`--mcp-config`) respondió una consulta con las dos herramientas.
-- Para publicar hay que regenerar `data/` desde live cuando BE-03 esté desplegado (hoy live responde 401). Eso lo hace MCP-03.
-
-### MCP-02 — Agregar `buscar_docs` con índice en español
-**Objetivo:** que el agente encuentre la sección de las docs que responde a su duda fiscal sin leer páginas completas.
-**Depende de:** MCP-01, DOC-01.
-**Skills:** `context7-mcp` (MiniSearch, `@orama/stemmers`), `superpowers:test-driven-development`.
-**Modelo sugerido (opcional):** opus.
-**Contexto:** D-04, D-08. Medición en `requisitos.md`: 14 de 16 aciertos con MiniSearch y stemmer en español sobre las secciones.
-**Alcance:**
-- En el build: leer `llms.txt`, descargar las `.md` de sección (DOC-01), sin bloques de código en el texto indexado, e indexar con MiniSearch.
- - `processTerm`: minúsculas, quitar acentos (NFD), stopwords de `@orama/stopwords` en español y stemmer de `@orama/stemmers` en español.
- - Boost al título.
- - El índice se serializa con `toJSON`.
-- `buscar_docs(consulta, limite=5)` devuelve título, URL `.md` de la sección, página y un extracto de unas 300 palabras como máximo.
-- Prueba de regresión con las 16 consultas de la medición y la sección esperada de cada una. "lista negra 69b" queda documentada como caso conocido si sigue fallando.
-**Criterios de aceptación (todos con `→ verificar`):**
-- 15 de 16 consultas de regresión, o más, traen la sección esperada en el top 3 → verificar: `npm test`.
-- La salida de `buscar_docs` no pasa de unos 10k tokens → verificar: prueba de tamaño.
-- El paquete completo pesa menos de 5 MB → verificar: `npm pack --dry-run`.
-**Pruebas focalizadas:** sí. `npm test` en `mcp/`.
-**Fuera de alcance:** embeddings (D-08).
-**Notas:** regresión 15/16 en el top 3, con las 16 consultas recuperadas de la sesión de planeación; caso conocido "buscar clave de producto sat" (sale primero la página "Catálogos SAT"). "lista negra 69b" ya pasa, y "estatus ante el sat" acepta `sat-validations/validar` (criterio `sat.cfdi.status`); ambas se asumieron y quedan documentadas en `test/search-regression.test.ts`.
-Sin prefijo, porque "sat" coincidía con cada `SatXxx` de los catálogos de Carta Porte; fuzzy solo para términos de 5 letras o más. El extracto se ancla en la primera coincidencia exacta, así "CFDI40147" muestra su fila de la tabla. Paquete de 342.5 kB (1.8 MB desempaquetado) y arranque de 0.6 s.
-El fixture `docs-corpus.json.gz` sale del build local de fapi-docs@73d326f, porque live todavía da 404 en las `.md` por sección. Después del deploy se regenera con `scripts/snapshot-docs.mjs`, y MCP-03 genera `data/` desde docs.fiscalapi.com.
-
-### MCP-03 — Agregar el workflow de build y publicación por hash
-**Objetivo:** que el paquete se regenere solo cuando cambian el OpenAPI o las docs, sin que nadie lo mantenga.
-**Depende de:** MCP-02, U-02.
-**Skills:** `fapi-release` (CI/CD sin pasos manuales), `context7-mcp` (documentación de npm trusted publishing).
-**Modelo sugerido (opcional):** opus. Publica en un registro público.
-**Contexto:** D-02, D-14. Trusted publishing exige un runner hospedado por GitHub, `id-token: write`, npm ≥11.5.1 y Node ≥22.14 (docs.npmjs.com/trusted-publishers, a revalidar en la tarea).
-**Alcance:**
-- Workflow en push a `main` (ruta `mcp/**`), semanal y manual:
- 1. build;
- 2. comparar el hash de la fuente con el de la última versión publicada;
- 3. si cambió, subir el patch y publicar con provenance.
-- El workflow queda deshabilitado para publicar (`if:` con una variable del repo) hasta que el usuario decida U-04.
-- Verificar en la documentación de npm si la primera publicación puede hacerse por OIDC o requiere una publicación manual del usuario, y dejar el paso exacto en las Notas.
-- Dependabot para `mcp/package.json`.
-**Criterios de aceptación (todos con `→ verificar`):**
-- El build sin cambios no publica → verificar: corrida manual del workflow con publicación en dry-run; el log dice "sin cambios".
-- Con un cambio simulado en la fuente, el dry-run publicaría una versión nueva → verificar: corrida con un hash forzado y `npm publish --dry-run` en el log.
-**Pruebas focalizadas:** ninguna (no aplica). La prueba es el dry-run.
-**Fuera de alcance:** publicar de verdad (U-04).
-**Notas:**
diff --git a/docs/ai-integracion/medicion.md b/docs/ai-integracion/medicion.md
deleted file mode 100644
index 5733ca2..0000000
--- a/docs/ai-integracion/medicion.md
+++ /dev/null
@@ -1,66 +0,0 @@
-# Integración asistida por IA: medición
-
-Los resultados de estas tareas van a `requisitos.md` → **Contexto medido**, con su fuente y sin datos personales (solo conteos y porcentajes). No se crea un archivo de resultados aparte.
-
-### MED-01 — Medir mezcla de tipos y complementos en el restore de prod
-**Objetivo:** saber qué tipos de CFDI y qué complementos timbran los clientes, para ordenar SK-04 a SK-09 y las evals.
-**Depende de:** —
-**Skills:** `fapi-investigate`, `fapi-local-env` (restore y sqlcmd local).
-**Modelo sugerido (opcional):** haiku. Son consultas de lectura, sin decisiones.
-**Contexto:** D-03; la métrica norte en `requisitos.md`.
-**Alcance:**
-- Confirmar que el restore local es de producción (`backupset.server_name`).
-- Contar, en los últimos 12 meses y por mes, las facturas timbradas por `Invoices.TypeCode`.
-- Contar las facturas con fila 1:1 en `Payroll`, `CartaPorte`, `ComercioExterior`, `InvoicePayment` y `GlobalInformation`.
-- Contar los tenants distintos que usan cada tipo o complemento.
-**Criterios de aceptación (todos con `→ verificar`):**
-- El origen del restore está confirmado → verificar: `SELECT TOP 1 server_name, backup_finish_date FROM msdb.dbo.backupset WHERE database_name='WebApi' ORDER BY backup_finish_date DESC`.
-- `requisitos.md` tiene una tabla de tipos y complementos con volumen, tenants y el orden resultante → verificar: `grep -n "Mezcla de tipos" docs/ai-integracion/requisitos.md`.
-- El orden de SK-04 a SK-09 y de los casos de EV-01 y EV-02 queda ajustado en el tablero si la medición lo cambia → verificar: revisar el diff del README.
-**Pruebas focalizadas:** ninguna (no aplica).
-**Fuera de alcance:** errores del PAC (MED-03); embudo de Test (MED-02).
-**Notas:**
-- Hechos corregidos: Nómina y Pagos viven en `Payrolls` e `InvoicePayments` (1:1 por índice único en `InvoiceId`); Información global no es tabla sino columnas `Invoices.GlobalInformation_*`.
-- Restore aparte `WebApi_ProdSnap_20261014` (READ_ONLY), borrado al terminar. Comercio Exterior: 0 facturas desde siempre; el orden de skills no cambia.
-
-### MED-02 — Medir embudo y tiempo al primer timbre en el restore de Test
-**Objetivo:** fijar la línea base de la métrica norte y medir cuántas cuentas abandonan en el paso de compra de prueba (para revisar D-09).
-**Depende de:** —
-**Skills:** `fapi-investigate`, `fapi-local-env`.
-**Modelo sugerido (opcional):** opus. Hay que interpretar el embudo y separar las cuentas internas de las reales.
-**Contexto:** D-09; la métrica norte en `requisitos.md`.
-**Alcance:**
-- Restaurar en local el respaldo de Test (origen en la memoria local `project_local_credentials`) y confirmar su `server_name`.
-- Por cohorte semanal de los últimos 6 meses:
- - owners creados;
- - con correo confirmado;
- - con suscripción de prueba activa;
- - con primer `Invoice` timbrado;
- - mediana de minutos desde la creación hasta el primer timbre;
- - porcentaje que timbra en menos de 24 h.
-- Excluir las cuentas internas (fixtures `rl-test-*`, dominios propios) con un criterio explícito.
-**Criterios de aceptación (todos con `→ verificar`):**
-- `requisitos.md` tiene la tabla del embudo, la línea base de la métrica norte y el criterio de exclusión → verificar: `grep -n "Embudo de Test" docs/ai-integracion/requisitos.md`.
-- El abandono en el paso de suscripción queda cuantificado y llevado al usuario como dato para D-09 → verificar: la cifra aparece en las Notas de la tarea.
-**Pruebas focalizadas:** ninguna (no aplica).
-**Fuera de alcance:** cambiar el onboarding (D-09).
-**Notas:**
-- Dato para D-09: 215 de 378 owners confirmados (56.9 %) nunca hacen la compra de prueba, y nadie timbra sin ella. Línea base: mediana de 1,424 min al primer timbre, 15.4 % en menos de 24 h.
-- "Suscripción" se midió como cualquier fila en `Features` (desde 2026-10-05 casi no hay `Type = 'Base'`). Restore aparte `WebApi_TestSnap` (READ_ONLY), borrado al terminar.
-
-### MED-03 — Medir el top de rechazos del PAC en Seq
-**Estado:** fuera de alcance por decisión del usuario (D-20); se quitó del tablero junto con U-01.
-**Objetivo:** saber qué códigos de rechazo del PAC son los más frecuentes, para dar prioridad a `fiscalapi-diagnostico` y a los casos de las evals.
-**Depende de:** U-01 (el usuario abre el túnel a Seq de producción).
-**Skills:** `fapi-investigate`.
-**Modelo sugerido (opcional):** haiku.
-**Contexto:** los rechazos no se guardan en la BD; solo quedan en Seq con las propiedades `PacCode` y `TypeCode` (log de `InvoiceIssuanceService`).
-**Alcance:**
-- En Seq, por el túnel, sacar los 30 `PacCode` más frecuentes de los últimos 90 días, con su `TypeCode` y el número de tenants afectados.
-- Cruzarlos con las tablas `/errors/*.md` de las docs para confirmar que cada código tiene página.
-**Criterios de aceptación (todos con `→ verificar`):**
-- `requisitos.md` tiene el top 30 con su volumen y si tiene página en las docs → verificar: `grep -n "Top de rechazos" docs/ai-integracion/requisitos.md`.
-- Los códigos sin página quedan como Notas de DOC-02 o como "Fuera de alcance" → verificar: revisar el diff.
-**Pruebas focalizadas:** ninguna (no aplica).
-**Fuera de alcance:** guardar los rechazos en la BD.
-**Notas:**
diff --git a/docs/ai-integracion/requisitos.md b/docs/ai-integracion/requisitos.md
deleted file mode 100644
index 47d3b61..0000000
--- a/docs/ai-integracion/requisitos.md
+++ /dev/null
@@ -1,170 +0,0 @@
-# Integración asistida por IA: requisitos
-
-## Contexto medido
-Fecha de las mediciones: 2026-10-14.
-
-**Lo que FiscalAPI ya tiene**
-- docs.fiscalapi.com (Next.js y MDX) publica `llms.txt` y una versión `.md` de cada página (agregando `.md` a la URL).
-- No hay `llms-full.txt`, ni OpenAPI público, ni MCP, ni skills para integradores. Las skills de `fapi-reusables/ai/skills` son todas internas.
-
-**OpenAPI**
-- fapi-back genera el documento con `Microsoft.AspNetCore.OpenApi` y Scalar, pero `MapOpenApi` solo se registra en Development (`WebConfigurePipeline`).
-- El documento se registra con un `BuildServiceProvider` intermedio (`Infrastructure/DependencyInjection`) y expone también el grupo v5 (`Controllers/V5/ValuesController`).
-- En nginx test y live, `/openapi` cae en `location /` (el front).
-- Pocos controllers V4 tienen `EndpointSummary` o `EndpointDescription`.
-
-**Superficie pública**
-- La colección `fapi-back/postman/collections/Fiscalapi API Reference` tiene 301 requests: 107 pares MÉTODO+ruta y 106 acciones. pdf y xml del portal son una sola acción, `downloads/{downloadToken}/{format}`.
-- Todos los pares tienen acción en `WebApi.App/Controllers/V4`.
-- En esos mismos controllers hay 52 acciones fuera de la colección:
- - `load`, `all` y `lookup` de DevExtreme;
- - Account (salvo register y login);
- - ProductsForSale, ProductTaxes, `webapp/manifests`;
- - WebhookSecrets y WebhookCliListener.
-
-**Spec generado desde Postman**
-- Está en `fapi-back/postman/specs/Fiscalapi Specification/Fiscalapi Specification.yaml`, sin rastrear en git: OpenAPI 3.1, 107 operaciones.
-- Le falta para ser contrato:
- - el servidor es `{{baseUrl}}`;
- - los path params van literales (``);
- - hay rutas con valores fijos;
- - hay un solo ejemplo por método y ruta;
- - no tiene `components/schemas` de los DTO ni `operationId`.
-- Su `info.description` sí es útil.
-
-**Tamaño de las docs**
-- 52 páginas, 427 secciones H2 y 2.3 MB de MDX (unos 600k tokens).
-- La página `invoices-by-value` pesa 647 KB y su sección "Modelo factura", 156 KB.
-
-**Prueba de búsqueda** (desechable, mismas 14 a 16 consultas de un integrador)
-- Sobre las 107 operaciones, MiniSearch 7.2.0 y Orama 3.1.18 con stemmer en español fallan en las consultas fiscales: "complemento de pago", "crear cliente receptor", "estatus ante el SAT", "descarga masiva".
-- Los embeddings locales (`Xenova/multilingual-e5-small` q8, transformers.js 4.3.1) descargan 130 MB de modelo, tardan 14 s en cargar y fallan en lo mismo.
-- Sobre las secciones H2 de las docs, MiniSearch con stemmer y stopwords en español (`@orama/stemmers`, `@orama/stopwords`) acierta 14 de 16.
-- El catálogo de las 107 operaciones (MÉTODO, ruta y resumen) ocupa unos 6.5k caracteres, alrededor de 1,600 tokens.
-
-**Mercado**
-- Ningún PAC ni API mexicana de CFDI publica un MCP oficial que timbre.
-- Sobre Facturapi hay MCP de la comunidad (`@codespar/mcp-facturapi`, con 20 herramientas).
-- Gigstack tiene un MCP de docs y un CLI con `context --json`; Alegra anuncia un MCP de solo lectura.
-- Los MCP generados desde OpenAPI que corren en local (Stainless con dynamic tools, @ivotoby/openapi-mcp-server) listan el catálogo completo en vez de buscar. Los que usan embeddings (Speakeasy Gram, el buscador de docs de Stainless) son hosteados.
-
-**npm**
-- `@fiscalapi/mcp` no existe (404).
-- Los paquetes `fiscalapi` y `fiscalapi-csf` los publica el usuario npm `fiscalapi`.
-- El SDK de MCP v2 (`@modelcontextprotocol/server`) se publicó el 2026-07-27 con la spec 2026-07-28.
-- Node 20 ya no tiene soporte.
-- npm trusted publishing exige un runner hospedado por GitHub.
-
-**Mezcla de tipos y complementos** (MED-01)
-- Fuente: restore local de producción (`backupset.server_name` = `649fafb1cc93`, corte 2026-10-15 01:00 UTC), facturas con `CreatedAt` del 2025-10-15 al corte. Todas tienen `Uuid`: 60,179 timbres de 100 tenants.
-- Volumen y tenants:
-
- | Tipo o complemento | Facturas | Tenants | Tenant mayor | Skill |
- |---|---|---|---|---|
- | Ingreso (`I`), sin Carta Porte | 38,828 | 97 (con `E`) | 41.5 % de `I` | SK-04 |
- | Egreso (`E`) | 252 | 9 | 37.3 % | SK-04 |
- | Pagos (`P`, fila en `InvoicePayments`) | 6,441 | 27 | 65.7 % | SK-05 |
- | Nómina (`N`, fila en `Payrolls`) | 14,481 | 9 | 82.3 % | SK-07 |
- | Carta Porte (`I` con fila en `CartaPortes`) | 177 | 2 | — | SK-08 |
- | Comercio Exterior (`ComercioExteriores`) | 0 (0 desde siempre) | 0 | — | SK-09 |
- | Información global (`Invoices.GlobalInformation_*`) | 46 | 11 | — | SK-04 |
-
-- Por mes, `I` crece de 51 (2025-10) a 10,263 (2026-09); `P` de 2 a 2,112; `N` sube a 4,636 en 2026-09. Carta Porte aparece en 2026-07 (14 a 75 al mes).
-- Información global es una columna nueva (primer registro 2026-10-10), así que 46 subestima su uso. No hay tabla de Impuestos Locales.
-- **Orden resultante:** ingreso-egreso, pagos, nómina, carta porte y comercio exterior. Pagos va antes que nómina porque la usan 3 veces más tenants (27 contra 9) y el 82 % de la nómina es de un solo tenant. Coincide con el orden de SK-04 a SK-09 y con EV-01 (ingreso y pagos) y EV-02 (nómina, carta porte y comercio exterior), así que el tablero no cambia.
-
-**Embudo de Test** (MED-02)
-- Fuente: restore local de Test (`backupset.server_name` = `83f15e2487c3`, corte 2026-10-15 01:00 UTC). Cohortes semanales (lunes) del 2026-04-13 al corte; la última semana está incompleta.
-- Owner = persona con `Id = CreatedBy`. Confirmado = `EmailConfirmed`. Compra de prueba = cualquier fila en `Features` del tenant. Desde 2026-10-05 casi no hay filas `Type = 'Base'` aunque sí compras, así que se cuenta cualquier tipo. Primer timbre = primer `Invoices.CreatedAt` del tenant.
-- **Criterio de exclusión:** personas de sistema (`-1`, `0`, `1`) y correos `@fiscalapi.com`, `@mailinator.com`, `rl-test-*`, los que contienen `test`, `prueba` o `e2e` y el correo del equipo. Quedan 397 de 404 owners.
-- Embudo de las 26 semanas:
-
- | Paso | Owners | De los owners | Del paso anterior |
- |---|---|---|---|
- | Creados | 397 | 100 % | — |
- | Con correo confirmado | 378 | 95.2 % | 95.2 % |
- | Con compra de prueba | 164 | 41.3 % | 43.4 % |
- | Con primer timbre | 121 | 30.5 % | 73.8 % |
- | Timbran en menos de 24 h | 61 | 15.4 % | 50.4 % |
-
-- Ningún owner timbró sin compra de prueba ni antes de ella: la compra es la puerta del primer timbre. **215 de 378 confirmados (56.9 %) nunca compran.** La mediana a la compra es de 333 min.
-- **Línea base de la métrica norte:** mediana de 1,424 min (23.7 h) de la creación al primer timbre, y 15.4 % de las cuentas nuevas timbra en menos de 24 h.
-- Por cohorte, la mediana va de 87 a 28,730 min. Con 5 a 23 owners por semana, la cifra semanal es ruidosa; se sigue la de 4 semanas móviles.
-
- | Semana | Owners | Confirmados | Compra | Timbran | < 24 h | Mediana (min) |
- |---|---|---|---|---|---|---|
- | 2026-04-13 | 12 | 11 | 4 | 4 | 3 | 618 |
- | 2026-04-20 | 5 | 5 | 1 | 1 | 0 | 5,958 |
- | 2026-04-27 | 18 | 18 | 11 | 9 | 2 | 8,706 |
- | 2026-05-04 | 5 | 5 | 4 | 4 | 3 | 314 |
- | 2026-05-11 | 10 | 10 | 1 | 0 | 0 | — |
- | 2026-05-18 | 20 | 19 | 9 | 6 | 4 | 715 |
- | 2026-05-25 | 14 | 13 | 6 | 3 | 2 | 135 |
- | 2026-06-01 | 12 | 11 | 3 | 2 | 1 | 3,431 |
- | 2026-06-08 | 18 | 17 | 9 | 6 | 2 | 9,147 |
- | 2026-06-15 | 15 | 15 | 4 | 4 | 1 | 28,731 |
- | 2026-06-22 | 11 | 11 | 8 | 8 | 1 | 15,954 |
- | 2026-06-29 | 11 | 10 | 4 | 4 | 2 | 3,078 |
- | 2026-07-06 | 11 | 10 | 3 | 2 | 0 | 15,139 |
- | 2026-07-13 | 17 | 17 | 7 | 5 | 3 | 798 |
- | 2026-07-20 | 14 | 14 | 4 | 4 | 2 | 1,281 |
- | 2026-07-27 | 14 | 14 | 6 | 6 | 4 | 87 |
- | 2026-08-03 | 23 | 22 | 8 | 7 | 2 | 3,853 |
- | 2026-08-10 | 15 | 15 | 8 | 6 | 4 | 888 |
- | 2026-08-17 | 15 | 14 | 8 | 5 | 2 | 2,794 |
- | 2026-08-24 | 15 | 14 | 8 | 4 | 2 | 727 |
- | 2026-08-31 | 20 | 16 | 6 | 5 | 3 | 703 |
- | 2026-09-07 | 18 | 17 | 8 | 4 | 3 | 207 |
- | 2026-09-14 | 18 | 17 | 9 | 5 | 3 | 1,119 |
- | 2026-09-21 | 18 | 17 | 7 | 4 | 2 | 3,728 |
- | 2026-09-28 | 17 | 17 | 7 | 4 | 2 | 1,623 |
- | 2026-10-05 | 17 | 15 | 6 | 5 | 4 | 249 |
- | 2026-10-12 (incompleta) | 14 | 14 | 5 | 4 | 4 | 128 |
-
-**Fuera de alcance:** el top de rechazos del PAC (MED-03), por decisión del usuario (D-20).
-
-## Requisitos funcionales
-- **RF-01 — OpenAPI público.** `GET https://{test,live}.fiscalapi.com/api/openapi/v4.json` responde sin autenticación con exactamente las 100 acciones de D-05. Cada operación tiene `operationId` estable y servidores reales.
- Aceptación: la prueba de equivalencia con la colección pasa y `curl` a ambos ambientes responde 200.
-- **RF-02 — Docs por sección.** Cada sección H2 de las docs tiene su versión `.md` propia, generada en el build.
- Aceptación: `curl` a la `.md` de una sección devuelve solo esa sección.
-- **RF-03 — Errores de Pagos y Comercio Exterior.** Las docs publican las tablas de errores de Pagos 2.0 y Comercio Exterior 2.0, igual que las de CFDI 4.0, Nómina y Carta Porte.
- Aceptación: las dos rutas `.md` responden 200.
-- **RF-04 — Skills.** Las 8 skills y los 3 comandos de D-03 se instalan como plugin en Claude Code, Codex, Cursor y Copilot.
- Aceptación: instalación desde el repo en los 4 hosts.
-- **RF-05 — MCP stdio.** `npx -y @fiscalapi/mcp` expone `listar_operaciones`, `obtener_operacion` y `buscar_docs` sin usar red en runtime.
- Aceptación: las 16 consultas de regresión de `buscar_docs` pasan.
-- **RF-06 — Evals.** Cada caso CFDI se corre en 3 variantes (sin nada, con skills, con skills más MCP), calificadas por un UUID real en test.
- Aceptación: el reporte de EV-03 incluye la tasa de éxito y los tokens por caso y variante.
-
-## Requisitos no funcionales
-- **RNF-01 — Cero mantenimiento adicional (D-02).** Nada hosteado. Todo lo derivado (OpenAPI, `.md` por sección, índice del MCP) se genera solo desde el código o las docs. Las skills enlazan, no copian.
-- **RNF-02 — Compatibilidad v4.** Publicar el OpenAPI no cambia ningún contrato: solo agrega metadatos y una ruta de lectura.
-- **RNF-03 — Sin exposición de lo interno.** Ninguna ruta Host, solo JWT, InternalKey, `webapp/`, de facturación del dashboard, register o login aparece en el OpenAPI. Lo garantiza la prueba de equivalencia.
-- **RNF-04 — Seguridad de llaves.** Las skills y el MCP nunca piden ni guardan llaves live. El MCP no invoca la API (D-07).
-- **RNF-05 — Presupuesto de contexto.**
- - `SKILL.md` en menos de 500 líneas.
- - La salida de una herramienta del MCP en menos de unos 10k tokens.
- - `listar_operaciones` completo en menos de unos 3k tokens.
-- **RNF-06 — Arranque del MCP.** Menos de 1 s y funcionando sin conexión: el índice va dentro del paquete y no se descarga ningún modelo.
-- **RNF-07 — Español.** Skills, herramientas, descripciones y mensajes en español (D-04).
-
-## Métrica norte
-- Mediana de minutos entre la creación del owner y su primer `Invoice` timbrado en la BD de Test, por cohorte semanal.
-- Porcentaje de cuentas nuevas que timbran en menos de 24 h.
-
-La línea base la fija MED-02. Se mide sin telemetría nueva.
-
-## Preguntas abiertas
-- **Q-01 — Marketplaces oficiales.** ¿Se solicita la inclusión en `claude-plugins-official` y en el catálogo curado de Codex, además del marketplace propio del repo?
- - Opciones: (a) solo el marketplace propio; (b) los dos.
- - Recomendación: (a) hasta tener el reporte de EV-03, porque la inclusión se pide con evidencia de calidad.
- - Bloquea: U-04.
-
-## Fuera de alcance
-- **MCP remoto u hosteado** (D-02): exige operar un servicio.
-- **Sandbox sin "compra"** (D-09): requiere código nuevo en billing. Se revisa con los datos de MED-02.
-- **CSD de prueba propios** (D-10): habría que renovarlos al vencer. Se sigue enlazando el ZIP de SW.
-- **`llms-full.txt`** (D-15): unos 600k tokens, no le sirve a un agente.
-- **Búsqueda semántica con embeddings:** medida sin ganancia (ver Contexto medido).
-- **Herramienta `invocar` en el MCP** (D-07).
diff --git a/docs/ai-integracion/skills.md b/docs/ai-integracion/skills.md
deleted file mode 100644
index a12236c..0000000
--- a/docs/ai-integracion/skills.md
+++ /dev/null
@@ -1,211 +0,0 @@
-# Integración asistida por IA: skills y plugin
-
-Todo va en fiscalapi-ai, en la rama `feat/ai-integracion`. Reglas comunes a SK-02 a SK-09:
-- **Formato agentskills.io:**
- - `name` igual al nombre de la carpeta;
- - `description` de 1024 caracteres como máximo, que diga qué hace la skill, cuándo usarla y cuándo no;
- - `SKILL.md` en menos de 500 líneas.
-- **D-03:** `references/` solo tiene índices de enlaces a las `.md` (de página o de sección) y árboles de decisión. Nada de payloads, tablas de catálogos ni mensajes de error copiados.
-- **Fuentes permitidas:**
- - las `.md` de docs.fiscalapi.com;
- - `/api/openapi/v4.json`;
- - las herramientas de `@fiscalapi/mcp` (opcionales: la skill funciona sin MCP);
- - la API de test con la llave del integrador (`GET /api/v4/catalogs/{name}/{search}`, `POST /api/v4/invoices/calculate`).
-- **Reglas de seguridad en todas:** nunca pedir ni escribir una llave live en el código; las llaves van en variables de entorno.
-
-### SK-01 — Armar el esqueleto del repo, los manifiestos y el CI de enlaces
-**Objetivo:** que el repo se instale como plugin en Claude Code, Codex, Cursor y Copilot, y que un enlace roto a las docs haga fallar el CI.
-**Depende de:** —
-**Skills:** `superpowers:writing-skills`, `anthropic-skills:skill-creator`, `plugin-authoring`. El formato de cada manifiesto se toma del repo de referencia que ya lo usa (stripe/ai, dotnet/skills, DevExpress/agent-skills), revisado con `gh api`.
-**Modelo sugerido (opcional):** opus. Fija la estructura de la que dependen todas las tareas.
-**Contexto:** D-03, D-12, RF-04.
-**Alcance:**
-- Estructura: `skills/`, `.claude-plugin/marketplace.json` y `plugin.json`, `.codex-plugin/`, `.cursor-plugin/`, `.github/plugin/`, `LICENSE` y README de instalación con el comando de cada host.
-- CI (GitHub Actions, runner hospedado por GitHub) en cada PR y una vez por semana:
- - valida el frontmatter contra el estándar;
- - extrae cada URL de docs.fiscalapi.com de `skills/**`;
- - exige 200 en cada URL y que cada ancla exista.
-- El MCP **no** se declara en los manifiestos todavía; eso depende de U-04 (D-07).
-**Criterios de aceptación (todos con `→ verificar`):**
-- Se instala en Claude Code desde la ruta local → verificar: `/plugin marketplace add ./` y `/plugin install`; `/plugin` lo muestra.
-- Los manifiestos de Codex, Cursor y Copilot son válidos según el formato de referencia → verificar: diff de estructura contra el repo de referencia, más la instalación local en los hosts disponibles.
-- El CI falla con un ancla rota → verificar: un PR de prueba con un ancla inventada sale en rojo.
-**Pruebas focalizadas:** ninguna (no aplica). La prueba es el CI de enlaces.
-**Fuera de alcance:** el contenido de las skills (SK-02 en adelante).
-**Notas:**
-- Manifiestos según D-18. Verificado: `claude plugin validate . --strict` en verde; instalación local en Claude Code 2.1.300 con una skill temporal (descubierta sin declararla, 0 MCP), desinstalada después. Codex, Cursor y Copilot no están instalados aquí: se validó su formato contra dotnet/skills, DevExpress/agent-skills y el código de `openai/codex`.
-- `scripts/check-skills.mjs` (frontmatter, 200 y anclas) sale en 1 con un ancla inventada, una página 404 o un `name` inválido (fixture local). El PR de prueba en rojo necesita push: es del usuario. La instalación en Cursor es local (`~/.cursor/plugins/local`); su marketplace es parte de U-04.
-
-### SK-02 — Escribir `fiscalapi-integracion` y `primer-timbre`
-**Objetivo:** que en un proyecto vacío el agente instale el SDK correcto, configure ambiente y llaves, y logre un UUID en test.
-**Depende de:** SK-01, DOC-01.
-**Skills:** `superpowers:writing-skills`, `fapi-cfdi-rules` (para no decir nada falso del CFDI).
-**Modelo sugerido (opcional):** opus. Es la skill más usada y fija el tono de todas.
-**Contexto:** D-01, D-03, D-09, D-10.
-**Alcance:**
-- `fiscalapi-integracion`, con:
- - tabla de ruteo "qué vas a construir → qué skill y qué `.md` de sección";
- - ambientes y headers (`X-API-KEY`, `X-TENANT-KEY`, `X-TIME-ZONE`);
- - elección de SDK según el lenguaje del proyecto;
- - modo por valores o por referencias;
- - flujo actual para obtener llaves de test (D-09) y datos de prueba (D-10);
- - regla crítica: un 5xx o timeout puede significar ya timbrado, así que se consulta el estado antes de reintentar.
-- Comando `primer-timbre`: detecta el lenguaje, instala el SDK, lee las llaves de variables de entorno y timbra una factura de ingreso en test.
-**Criterios de aceptación (todos con `→ verificar`):**
-- En un proyecto Node vacío y en uno .NET vacío, `primer-timbre` produce un UUID en test → verificar: correrlo con una llave de test local y consultar el UUID con `GET /api/v4/invoices/{id}`.
-- La skill se dispara sola con "integra FiscalAPI" → verificar: el log de la sesión de Claude Code muestra la skill cargada.
-- El CI de enlaces pasa → verificar: CI en verde.
-**Pruebas focalizadas:** ninguna (no aplica). Lo cubre EV-01.
-**Fuera de alcance:** complementos (SK-04 en adelante).
-**Notas:**
-- `primer-timbre` dio UUID en Node (`6b92a718-…`) y en .NET (`fba6dd24-…`), confirmados con `GET /invoices/{id}`. Corrió contra el back local (PAC de pruebas) con una cuenta creada por el producto (registro, Mailpit y Stripe test). La skill se dispara con "Quiero integrar FiscalAPI…".
-- Las URL `.md` por sección (DOC-01) dan 404 en live hasta el deploy de fapi-docs. El check pasa contra el build local (138 enlaces). `references/sdks.md` documenta las trampas reales de los README: Node `FiscalApiClient`, `double` en Java y fechas.
-- Con resultado incierto (5xx o timeout), el back no guarda la factura, así que "Listar facturas" no la muestra y el camino real es soporte con el `traceIdentifier`. La regla de la skill sigue a las docs: falta decidir si el back debe registrarla.
-
-### SK-03 — Escribir `fiscalapi-diagnostico` y `explicar-error`
-**Objetivo:** que ante una respuesta de error el agente distinga una validación de FiscalAPI, un rechazo del PAC o un 5xx, y proponga la corrección leyendo la tabla correcta.
-**Depende de:** SK-01, DOC-02.
-**Skills:** `superpowers:writing-skills`, `fapi-cfdi-rules`.
-**Modelo sugerido (opcional):** opus.
-**Contexto:** D-03. El top de rechazos del PAC (MED-03) quedó fuera de alcance (D-20).
-**Alcance:**
-- Árbol de decisión:
- - 400 con `propertyName` → validación;
- - códigos `CFDI40*`, `CRP*`, `NOM*`, `CCE*`, `CP*` → la `.md` de `/errors/…` que corresponda;
- - 5xx o timeout → consultar el estado antes de reintentar.
-- Comando `explicar-error `.
-**Criterios de aceptación (todos con `→ verificar`):**
-- Con tres respuestas reales de ejemplo (una de cada tipo), `explicar-error` llega a la página y la fila correctas → verificar: transcript de la sesión.
-- El CI de enlaces pasa → verificar: CI en verde.
-**Pruebas focalizadas:** ninguna (no aplica).
-**Fuera de alcance:** guardar o consultar rechazos históricos.
-**Notas:**
-- Contrato verificado en fapi-back: un rechazo del PAC responde el status del PAC con `message` = `"CÓDIGO - texto"`, `details` = detalle del PAC y `data` = null (`InvoiceIssuanceTests`, `PacRejectionCodeTests`). Las respuestas de prueba se armaron con ese contrato y la fila real CFDI40147, sin timbrar contra el PAC. `claude -p /fiscalapi:explicar-error` llegó a la página y la fila correctas en los 3 casos.
-- `errors/pagos-20` aún no está publicada: el CI sale en rojo hasta desplegar fapi-docs (DOC-02). Contra el build local pasa (`FISCALAPI_DOCS_ORIGIN=http://localhost:3100`). Sin tabla de `CCE` (DOC-04), la skill usa el texto del rechazo. Solo los H2 tienen ancla en el sitio.
-
-### SK-04 — Escribir `fiscalapi-ingreso-egreso`
-**Objetivo:** que el agente arme bien una factura de ingreso, una nota de crédito y una factura global.
-**Depende de:** SK-02, MED-01.
-**Skills:** `superpowers:writing-skills`, `fapi-cfdi-rules`.
-**Modelo sugerido (opcional):** opus.
-**Contexto:** D-03. El orden de las secciones lo fija MED-01.
-**Alcance:**
-- Decisiones de régimen, uso CFDI, objeto de impuesto, método y forma de pago, factura global y relación de la nota de crédito.
-- Cada decisión enlaza a su sección `.md`.
-- Las claves del SAT se buscan con la API de catálogos de test.
-**Criterios de aceptación (todos con `→ verificar`):**
-- Con los prompts de ingreso, nota de crédito y global, el agente timbra en test → verificar: UUID y estado consultado (caso de EV-01).
-- El CI de enlaces pasa → verificar: CI en verde.
-**Pruebas focalizadas:** ninguna (no aplica).
-**Fuera de alcance:** —
-**Notas:**
-- Timbrado en el back local (PAC de pruebas) con el plugin. Ingreso PUE `e84b01e8-…`. Nota de crédito `fb6129f0-…`: el XML trae `TipoRelacion="01"` al ingreso. Global mensual `c4405f7f-…` (XAXX010101000, 616, S01, CP igual al de expedición). Estatus consultado (simulado fuera de Production).
-- Sin las skills del usuario (`--setting-sources project,local`), "factura global" dispara `fiscalapi-ingreso-egreso`. Con ellas, el caso global cargó `fapi-cfdi-rules`, una skill personal: las pruebas de disparo van aisladas.
-- Lo que el back no valida antes del PAC: PPD con forma distinta de 99, y periodicidad 05 o meses 13-18 en la global; además, con `taxObjectCode` 06 u 08 descarta `itemTaxes`. `GET invoices/{id}?details=true` no devuelve `relatedInvoices`, aunque el XML sí los trae. La docs (`invoices-by-value:5943`) etiqueta `E` como "Ingreso".
-
-### SK-05 — Escribir `fiscalapi-pagos`
-**Objetivo:** que el agente emita un REP 2.0 correcto para facturas PPD y parcialidades.
-**Depende de:** SK-02, MED-01.
-**Skills:** `superpowers:writing-skills`, `fapi-cfdi-rules`.
-**Modelo sugerido (opcional):** opus.
-**Contexto:** D-03; errores `CRP*` (DOC-02).
-**Alcance:** PUE frente a PPD, saldo anterior e insoluto, parcialidades, tipo de cambio y equivalencia, impuestos del pago; cada punto con su sección `.md`.
-**Criterios de aceptación (todos con `→ verificar`):**
-- El agente timbra en test una factura PPD y su complemento de pago → verificar: los dos UUID y su estado (caso de EV-01).
-- El CI de enlaces pasa → verificar: CI en verde.
-**Pruebas focalizadas:** ninguna (no aplica).
-**Fuera de alcance:** —
-**Notas:**
-- Timbrado con el plugin, sin skills del usuario. PPD `99b472e9-…` (PPD/99, total 2320). Pago `60fc947f-…`: tipo P, XXX, CP01, sin forma ni método en la raíz; DoctoRelacionado a la PPD, parcialidad 1 (2320 → 1160 → 1160) y BaseDR proporcional de 1000. Estatus consultado (simulado).
-- Defecto encontrado: `FechaPago` sale en UTC (18:58) mientras `Fecha` sale en hora local (12:58). Quedó en `known-issues.md` de fapi-back (`fapi-back@c9d1baff`), con prioridad alta.
-- FiscalAPI no busca la factura PPD ni calcula saldos, y no rechaza forma o método de pago en la raíz de un P (lo hace el PAC). Las docs llaman `amount` al importe pagado del documento, pero el campo real es `paymentAmount`.
-
-### SK-06 — Escribir `fiscalapi-ciclo-de-vida` y `revisar-integracion`
-**Objetivo:** que el agente cubra lo que pasa después de timbrar (cancelar, consultar estado, XML y PDF, correo y webhooks) y revise una integración existente.
-**Depende de:** SK-02.
-**Skills:** `superpowers:writing-skills`, `fapi-cfdi-rules`.
-**Modelo sugerido (opcional):** haiku para el borrador; opus revisa.
-**Contexto:** D-03. `fiscalapi-cli` cubre webhooks en local (`listen`, `trigger`); la skill enlaza a su página de docs.
-**Alcance:**
-- Skill con cancelación (motivos y sustitución), estado ante el SAT, descarga de XML y PDF, envío por correo y verificación de la firma de webhooks.
-- Comando `revisar-integracion`: busca llaves `sk_live_` o llaves en código, falta de `X-TIME-ZONE`, reintentos a ciegas tras un 5xx y webhooks sin verificar la firma.
-**Criterios de aceptación (todos con `→ verificar`):**
-- `revisar-integracion` detecta 4 defectos plantados en un proyecto de prueba → verificar: transcript con los 4 hallazgos.
-- El CI de enlaces pasa → verificar: CI en verde.
-**Pruebas focalizadas:** ninguna (no aplica).
-**Fuera de alcance:** descarga masiva (se evalúa con MED-01 si merece su propia skill).
-**Notas:**
-- `revisar-integracion`, sin skills del usuario, encontró en un proyecto de prueba los 4 defectos plantados: llave `sk_live_` y tenant en `config.js`, falta de `X-TIME-ZONE`, reintento a ciegas tras un 5xx y webhook sin verificar la firma. Agregó otros 2, también reales: URL `live` en el código y `succeeded` sin revisar.
-- El prefijo `sk_live_` sí es de FiscalAPI: `ApiKeyValueGenerator.cs` genera `sk_live_`, `sk_test_` o `sk_development_` más un GUID con guiones bajos. La firma de webhooks es Standard Webhooks (HMAC-SHA256 sobre el cuerpo crudo).
-- Sin resolver: las docs (`webhooks/content.mdx:13`) dicen que sin `X-TIME-ZONE` las fechas de webhooks vienen en UTC con `Z`, pero el middleware usaría `America/Mexico_City`. La skill solo recomienda mandar siempre el header.
-
-### SK-07 — Escribir `fiscalapi-nomina`
-**Objetivo:** que el agente emita una nómina 1.2 correcta (empleador, empleado, percepciones y deducciones).
-**Depende de:** SK-02, MED-01.
-**Skills:** `superpowers:writing-skills`, `fapi-cfdi-rules`.
-**Modelo sugerido (opcional):** opus.
-**Contexto:** D-03; errores `NOM*` (página existente).
-**Alcance:** datos de empleador y empleado en la persona, tipos de nómina, percepciones, deducciones, otros pagos e incapacidades; cada punto con su sección `.md`.
-**Criterios de aceptación (todos con `→ verificar`):**
-- El agente timbra en test una nómina ordinaria → verificar: UUID y estado (caso de EV-02).
-- El CI de enlaces pasa → verificar: CI en verde.
-**Pruebas focalizadas:** ninguna (no aplica).
-**Fuera de alcance:** —
-**Notas:**
-- Timbrado con el plugin, sin skills del usuario: nómina ordinaria quincenal `a153d832-…`, de EKU9003173C9 (registro patronal B5510768108) a FUNK671228PH6. El XML no trae `FormaPago`; trae receptor 605/CN01, contrato 01 con régimen 02, `P112W`, ISR 520 en impuestos retenidos y subsidio 002 en 0. Estatus consultado (simulado).
-- Riesgo del back: no limpia `paymentFormCode` en N (`ComprobanteBuilder.cs:15`) y no fuerza 605/CN01, así que la skill pide no mandar el primero y sí mandar los dos últimos. Los ejemplos de C# y Python de las docs usan 601/G01 y `PaymentFormCode`, y el cURL "por referencias" usa datos por valores.
-- Las guías `md-version` de nómina en sat-docs están vacías: las reglas salen del PDF y de la matriz `errors/nomina-12`.
-
-### SK-08 — Escribir `fiscalapi-carta-porte`
-**Objetivo:** que el agente emita una Carta Porte 3.1 correcta (ubicaciones, mercancías y autotransporte).
-**Depende de:** SK-02, MED-01.
-**Skills:** `superpowers:writing-skills`, `fapi-cfdi-rules`.
-**Modelo sugerido (opcional):** opus.
-**Contexto:** D-03; errores de Carta Porte (página existente).
-**Alcance:** tipo de CFDI (ingreso o traslado), ubicaciones y distancias, mercancías y material peligroso, autotransporte y figuras; cada punto con su sección `.md` y los catálogos por la API de test.
-**Criterios de aceptación (todos con `→ verificar`):**
-- El agente timbra en test una Carta Porte de traslado por autotransporte → verificar: UUID y estado (caso de EV-02).
-- El CI de enlaces pasa → verificar: CI en verde.
-**Pruebas focalizadas:** ninguna (no aplica).
-**Fuera de alcance:** transporte marítimo, aéreo y ferroviario, salvo que MED-01 diga lo contrario.
-**Notas:**
-- Timbrado con el plugin, sin skills del usuario: traslado `f904a48d-…`, T/XXX con receptor igual al emisor y S01. Origen CMX y destino COA (850 km solo en el destino, igual que `TotalDistRec`), 10 cajas de 120 kg, C2 sin remolques, TPAF01 y operador 01. Estatus consultado (simulado).
-- `FechaHoraSalidaLlegada` sale en UTC, el mismo defecto que `FechaPago`; quedó en `known-issues.md` (`fapi-back@f6ca9a69`). La API de catálogos no expone `c_Estado` de 3 letras ni códigos postales, así que la congruencia CP/colonia la valida solo el PAC.
-- Las docs (`crear-factura-de-carta-porte:126`) dicen que en T `itemCode` debe ser igual a `bienesTranspId`. El caso 14 del SAT usa 78101800, y aquí timbró con 11121900: ninguna regla CP lo exige.
-
-### SK-09 — Escribir `fiscalapi-comercio-exterior`
-**Objetivo:** que el agente emita un CFDI de exportación con Comercio Exterior 2.0 correcto.
-**Depende de:** SK-02, MED-01.
-**Skills:** `superpowers:writing-skills`, `fapi-cfdi-rules`.
-**Modelo sugerido (opcional):** opus.
-**Contexto:** D-03; errores `CCE*` (DOC-02).
-**Alcance:** clave de pedimento, Incoterm, receptor extranjero (`NumRegIdTrib`), mercancías, fracción arancelaria y tipo de cambio; cada punto con su sección `.md`.
-**Criterios de aceptación (todos con `→ verificar`):**
-- El agente timbra en test una exportación definitiva → verificar: UUID y estado (caso de EV-02).
-- El CI de enlaces pasa → verificar: CI en verde.
-**Pruebas focalizadas:** ninguna (no aplica).
-**Fuera de alcance:** —
-**Notas:**
-- Timbrado con el plugin, sin skills del usuario: exportación definitiva `72064f38-…` con I/02, USD y TipoCambio = TipoCambioUSD 18.3528 (DOF del 14/10, tomado de Banxico por el agente). Receptor XEXX010101000 con USA, 123456789, 616 y S01. `NoIdentificacion` del concepto igual al de la mercancía, ValorDolares 400 y TotalUSD 400.00. Estatus consultado (simulado).
-- El primer intento lo rechazó el PAC con CCE139: con EE. UU. el `estado` del receptor es clave de `c_Estado` (`TX`), no texto. Corregí la skill con la regla de la matriz (CCE139, CCE140).
-- FiscalAPI no exige `02`, el Incoterm ni la congruencia entre conceptos y mercancías, y con USD sin `exchangeRate` escribe TipoCambio 1. El cURL de las docs trae un `valorDolares` (23.47) que no cuadra con 400 USD.
-
-### SK-10 — Validar en el check semanal los enlaces "Docs:" del OpenAPI
-**Objetivo:** que un título renombrado o retirado en fapi-docs no deje rotos, sin que nadie lo note, los enlaces "Docs:" de las descripciones del OpenAPI público.
-**Depende de:** SK-01, BE-05.
-**Skills:** ninguna de dominio. Se extiende `scripts/check-skills.mjs` de SK-01.
-**Modelo sugerido (opcional):** haiku. Reutiliza el validador de enlaces que ya existe.
-**Contexto:** D-02, RF-01. BE-04 y BE-05 escribieron a mano 84 enlaces a anclas H2 de docs.fiscalapi.com en las `EndpointDescription` de fapi-back, y solo se verificaron una vez. El check de SK-01 corre en cada PR y cada lunes, pero solo lee `skills/**`.
-**Alcance:**
-- El check también descarga `/api/openapi/v4.json`: de live por omisión, o de la URL de `FISCALAPI_OPENAPI_URL` para validar contra un back local. Extrae cada URL de docs.fiscalapi.com que aparece en `info.description` y en las `description` de cada operación.
-- Esas URL se validan con la misma regla que las de las skills: 200 y ancla existente, contra `FISCALAPI_DOCS_ORIGIN` cuando está definido. Cada hallazgo indica el `operationId` donde está el enlace roto.
-- Corre en el mismo job `Check`, sin un workflow nuevo.
-**Criterios de aceptación (todos con `→ verificar`):**
-- Los 84 enlaces del OpenAPI se validan y pasan → verificar: `FISCALAPI_OPENAPI_URL` apuntando al back local de `fapi-back-ai` y `node scripts/check-skills.mjs`, con el conteo de enlaces del OpenAPI en la salida.
-- Un ancla rota en una descripción hace fallar el check y nombra la operación → verificar: un OpenAPI de prueba con un ancla inventada sale con código 1 y el `operationId` en el mensaje.
-**Pruebas focalizadas:** ninguna (no aplica). La prueba es el propio check.
-**Fuera de alcance:** las 31 operaciones que todavía no tienen línea "Docs:" (Webhooks, Tickets, Portal, AutoInvoice, Events).
-**Notas:**
-- `FISCALAPI_OPENAPI_URL` acepta una URL o la ruta de un `.json`. Contra el OpenAPI del código actual hay 87 enlaces (84 de operaciones y 3 de `info`). Con `FISCALAPI_DOCS_ORIGIN` en el build local de fapi-docs, los 100 enlaces pasan. Un ancla inventada sale con código 1 y nombra la operación (`OpenAPI transferStamps`).
-- Hoy live responde 401 en `/api/openapi/v4.json`, porque BE-03 no está desplegado. Por eso el check de cada PR y cada lunes sale en rojo hasta el deploy de fapi-back. Es a propósito: el plugin y el MCP dependen de ese documento, y saltarlo escondería una regresión.
-- También sale en rojo `errors/pagos-20.md` (SK-03), que es de DOC-02 y aún no está desplegada.
diff --git a/mcp/.gitignore b/mcp/.gitignore
deleted file mode 100644
index b0bc5f5..0000000
--- a/mcp/.gitignore
+++ /dev/null
@@ -1,4 +0,0 @@
-node_modules/
-dist/
-# Lo genera npm run build:data desde el OpenAPI; se publica en el paquete, no en el repo.
-data/
diff --git a/mcp/README.md b/mcp/README.md
deleted file mode 100644
index e1531a0..0000000
--- a/mcp/README.md
+++ /dev/null
@@ -1,64 +0,0 @@
-# @fiscalapi/mcp
-
-Servidor MCP de solo lectura para integrar [FiscalAPI](https://docs.fiscalapi.com) (CFDI 4.0) desde tu agente de código. Le da al agente el catálogo de operaciones de la API, el esquema completo de cada una y una búsqueda en la documentación, para que escriba la integración sin adivinar rutas, campos ni reglas fiscales.
-
-No usa red ni llaves: el OpenAPI y un índice de la documentación vienen dentro del paquete y el servidor solo lee esos archivos. Nunca llama a la API.
-
-## Instalación
-
-Requiere Node.js 22 o posterior.
-
-```
-npx -y @fiscalapi/mcp
-```
-
-En Claude Code:
-
-```
-claude mcp add fiscalapi -- npx -y @fiscalapi/mcp
-```
-
-En otros clientes MCP, registra un servidor stdio con el comando `npx` y los argumentos `-y @fiscalapi/mcp`.
-
-## Herramientas
-
-| Herramienta | Qué hace |
-| --- | --- |
-| `listar_operaciones` | Lista las operaciones agrupadas por tag: operationId, método, ruta y resumen. Acepta `tag` (opcional) para filtrar, por ejemplo `Invoices`. |
-| `obtener_operacion` | Devuelve en JSON una operación por su `operationId` (por ejemplo `createInvoice`): descripción con el enlace a la documentación, parámetros, cuerpo y respuestas con los esquemas resueltos. Si el id no existe, sugiere los parecidos. |
-| `buscar_docs` | Busca en las secciones de docs.fiscalapi.com: guías, reglas fiscales del SAT, catálogos, errores del PAC por código (por ejemplo `CFDI40147`) y la referencia de cada endpoint. Devuelve título, página, la URL `.md` de la sección y un extracto. Acepta `consulta` y `limite` (opcional, de 1 a 10; 5 por omisión). |
-
-El flujo normal es `listar_operaciones` para encontrar el endpoint y `obtener_operacion` para ver su contrato. `buscar_docs` responde el resto: qué regla aplica, qué significa un error o qué valor de catálogo usar. Para leer una sección completa, el agente descarga su URL `.md`.
-
-## Desarrollo
-
-```
-npm install
-npm run build:data # genera data/ desde el OpenAPI de live y docs.fiscalapi.com
-npm run build
-npm test
-```
-
-`build:data` lee el OpenAPI de `https://live.fiscalapi.com/api/openapi/v4.json`. Para usar otra fuente, pásala como argumento o en `FISCALAPI_OPENAPI_SOURCE` (URL o ruta de archivo):
-
-```
-FISCALAPI_OPENAPI_SOURCE=test/fixtures/openapi-v4.json npm run build:data
-```
-
-La documentación se descarga de `https://docs.fiscalapi.com` (`llms.txt` y la `.md` de cada sección). Para usar otro origen, pásalo con `--docs-origin` o en `FISCALAPI_DOCS_ORIGIN`:
-
-```
-npm run build:data -- test/fixtures/openapi-v4.json --docs-origin http://localhost:3000
-```
-
-`data/` no se versiona: lo genera el build y se publica dentro del paquete. `data/source.json` guarda el hash SHA-256 del OpenAPI y, en `docs`, el origen, el hash y el número de documentos de la documentación.
-
-Las pruebas no usan la red: sirven en local `test/fixtures/openapi-v4.json` y `test/fixtures/docs-corpus.json.gz`, una copia del `llms.txt` y de todas sus `.md`. Para regenerar esa copia:
-
-```
-node scripts/snapshot-docs.mjs https://docs.fiscalapi.com test/fixtures/docs-corpus.json.gz
-```
-
-## Licencia
-
-[MPL-2.0](https://github.com/FiscalAPI/fiscalapi-ai/blob/main/LICENSE)
diff --git a/mcp/scripts/build-data.mjs b/mcp/scripts/build-data.mjs
deleted file mode 100644
index 559e6fa..0000000
--- a/mcp/scripts/build-data.mjs
+++ /dev/null
@@ -1,114 +0,0 @@
-// Genera mcp/data desde el OpenAPI público de FiscalAPI y la documentación de docs.fiscalapi.com.
-//
-// Uso: node scripts/build-data.mjs [fuente] [--docs-origin ] [--out ]
-// fuente: URL http(s) o ruta de archivo. Por omisión FISCALAPI_OPENAPI_SOURCE o el OpenAPI de live.
-// --docs-origin: origen del que se descargan llms.txt y las secciones .md. Por omisión
-// FISCALAPI_DOCS_ORIGIN o https://docs.fiscalapi.com.
-// --out: directorio de salida. Por omisión mcp/data.
-import { createHash } from 'node:crypto';
-import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
-import path from 'node:path';
-import { fileURLToPath } from 'node:url';
-import { parseArgs } from 'node:util';
-
-import MiniSearch from 'minisearch';
-
-import { DOCS_INDEX_OPTIONS } from '../src/docs-search.ts';
-import { DOCS_SITE, DocsError, downloadMarkdown, fetchLlms, normalizeOrigin, parseLlms, toDocument } from './docs.mjs';
-import { buildCatalog, buildOperation, extractOperations, OpenApiError, parseOpenApi, serializeOperation } from './openapi.mjs';
-
-const DEFAULT_SOURCE = 'https://live.fiscalapi.com/api/openapi/v4.json';
-const DEFAULT_OUT_DIR = fileURLToPath(new URL('../data', import.meta.url));
-const FETCH_TIMEOUT_MS = 60_000;
-
-async function main() {
- const { positionals, values } = parseArgs({
- allowPositionals: true,
- options: { out: { type: 'string' }, 'docs-origin': { type: 'string' } },
- });
- const source = positionals[0] ?? process.env.FISCALAPI_OPENAPI_SOURCE ?? DEFAULT_SOURCE;
- const docsOrigin = normalizeOrigin(values['docs-origin'] ?? process.env.FISCALAPI_DOCS_ORIGIN ?? DOCS_SITE);
- const outDir = path.resolve(values.out ?? DEFAULT_OUT_DIR);
-
- const text = await readSource(source);
- const doc = parseOpenApi(text);
- const operations = extractOperations(doc);
- const catalog = buildCatalog(operations);
- const docs = await downloadDocs(docsOrigin);
-
- const staging = `${outDir}.tmp-${process.pid}`;
- await rm(staging, { recursive: true, force: true });
- try {
- await mkdir(path.join(staging, 'operations'), { recursive: true });
- let largest = { id: '', chars: 0 };
- for (const entry of operations) {
- const json = serializeOperation(buildOperation(doc, entry));
- if (json.length > largest.chars) largest = { id: entry.id, chars: json.length };
- await writeFile(path.join(staging, 'operations', `${entry.id}.json`), json);
- }
- await writeFile(path.join(staging, 'catalog.json'), JSON.stringify(catalog));
- await writeFile(path.join(staging, 'docs-index.json'), docs.indexJson);
- const sha256 = createHash('sha256').update(text).digest('hex');
- const sourceInfo = {
- source,
- sha256,
- operations: catalog.length,
- docs: { origin: docsOrigin, sha256: docs.sha256, documents: docs.documents },
- };
- await writeFile(path.join(staging, 'source.json'), JSON.stringify(sourceInfo, null, 2));
-
- // Se reemplaza de golpe para que un build fallido nunca deje data/ a medias.
- await rm(outDir, { recursive: true, force: true });
- await rename(staging, outDir);
-
- console.error(`build-data: ${catalog.length} operaciones en ${outDir}`);
- console.error(`build-data: la más grande es ${largest.id} con ${largest.chars} caracteres`);
- console.error(`build-data: sha256 ${sha256}`);
- console.error(`build-data: ${docs.documents} documentos de ${docsOrigin}; el índice mide ${Buffer.byteLength(docs.indexJson)} bytes`);
- console.error(`build-data: sha256 de la documentación ${docs.sha256}`);
- } finally {
- await rm(staging, { recursive: true, force: true });
- }
-}
-
-// El hash cubre el llms.txt y cada Markdown en el orden del llms.txt: cambia solo si cambia la documentación.
-async function downloadDocs(origin) {
- const llms = await fetchLlms(origin);
- const entries = parseLlms(llms);
- const markdown = await downloadMarkdown(origin, entries.map((entry) => entry.url));
-
- const hash = createHash('sha256').update(llms);
- for (const body of markdown) hash.update(body);
- const index = new MiniSearch(DOCS_INDEX_OPTIONS);
- index.addAll(entries.map((entry, i) => toDocument(entry, markdown[i])));
- return { indexJson: JSON.stringify(index), sha256: hash.digest('hex'), documents: entries.length };
-}
-
-async function readSource(source) {
- if (!/^https?:\/\//i.test(source)) {
- try {
- return await readFile(source, 'utf8');
- } catch (error) {
- throw new OpenApiError(`no se pudo leer ${source}: ${error.message}`);
- }
- }
- let response;
- try {
- response = await fetch(source, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
- } catch (error) {
- throw new OpenApiError(`no se pudo descargar ${source}: ${error.message}`);
- }
- if (response.status !== 200) {
- throw new OpenApiError(`${source} respondió HTTP ${response.status}; se esperaba 200.`);
- }
- return response.text();
-}
-
-try {
- await main();
-} catch (error) {
- const expected = error instanceof OpenApiError || error instanceof DocsError;
- const message = expected ? error.message : (error?.stack ?? String(error));
- console.error(`build-data: ${message}`);
- process.exitCode = 1;
-}
diff --git a/mcp/scripts/docs.mjs b/mcp/scripts/docs.mjs
deleted file mode 100644
index 74c21e5..0000000
--- a/mcp/scripts/docs.mjs
+++ /dev/null
@@ -1,169 +0,0 @@
-// Documentación de docs.fiscalapi.com para el índice de buscar_docs. parseLlms y cleanSection son
-// transformaciones puras; fetchLlms y downloadMarkdown son la única parte que usa la red.
-
-// El llms.txt siempre enlaza a este sitio; para descargar de otro origen se cambia solo el prefijo.
-export const DOCS_SITE = 'https://docs.fiscalapi.com';
-export const DOWNLOAD_CONCURRENCY = 8;
-export const FETCH_TIMEOUT_MS = 60_000;
-
-const SITE_URL = `${escapeRegExp(DOCS_SITE)}/\\S+?\\.md`;
-// Una página: "- [Título](https://docs.fiscalapi.com/ruta.md): descripción".
-const PAGE_LINE = new RegExp(`^- \\[([^\\]]+)\\]\\((${SITE_URL})\\)`);
-// Una sección de la página anterior: " - [Título](https://docs.fiscalapi.com/ruta/slug.md)".
-const SECTION_LINE = new RegExp(`^ {2}- \\[([^\\]]+)\\]\\((${SITE_URL})\\)`);
-const ANY_MD_LINK = new RegExp(`\\((${SITE_URL})\\)`, 'g');
-
-const CODE_BLOCK = /```[\s\S]*?```/g;
-const HEADING = /^#{1,2} (.+)$/m;
-const ANNOTATION = /\{\{([\s\S]*?)\}\}/g;
-const ANNOTATION_TAG = /\btag:\s*'([^']*)'/;
-const ANNOTATION_LABEL = /\blabel:\s*'([^']*)'/;
-const TAG_WITH_NAME = /<[A-Za-z][^>]*?\sname="([^"]*)"[^>]*>/g;
-// - o
- : el valor, sin comillas.
-const ITEM_VALUE = /
- ]*?\bvalue=(?:'"?([^'"]*)"?'|"([^"]*)")[^>]*>/g;
-const ANY_TAG = /<\/?[A-Za-z][^>]*>/g;
-const LINK = /!?\[([^\]]*)\]\([^)]*\)/g;
-const INLINE_CODE_MARK = /`/g;
-const LINE_INDENT = /^[ \t]+|[ \t]+$/gm;
-const REPEATED_BLANK_LINES = /\n{3,}/g;
-
-export class DocsError extends Error {}
-
-/**
- * Los documentos a indexar, sin repetir URL y en el orden del llms.txt: cada sección con el título
- * de su página, y cada página sin secciones entera. Una página con secciones no se indexa entera
- * porque es la suma de ellas y competiría con sus propias secciones.
- */
-export function parseLlms(text) {
- const pages = new Map();
- let current;
- for (const line of text.split(/\r?\n/)) {
- const page = PAGE_LINE.exec(line);
- if (page) {
- const [, title, url] = page;
- current = pages.get(url) ?? { title, url, sections: [] };
- pages.set(url, current);
- continue;
- }
- const section = SECTION_LINE.exec(line);
- if (section && current) current.sections.push(section[2]);
- }
-
- const documents = new Map();
- for (const page of pages.values()) {
- const urls = page.sections.length > 0 ? page.sections : [page.url];
- for (const url of urls) if (!documents.has(url)) documents.set(url, { url, page: page.title });
- }
- if (documents.size === 0) throw new DocsError(`el llms.txt no enlaza ninguna página .md de ${DOCS_SITE}.`);
- return [...documents.values()];
-}
-
-/** Todas las URLs .md del llms.txt, páginas y secciones, sin repetir. */
-export function listMarkdownUrls(text) {
- return [...new Set([...text.matchAll(ANY_MD_LINK)].map((match) => match[1]))];
-}
-
-/** La ruta de una URL del sitio, por ejemplo /invoices/cancelar-factura.md. */
-export function sitePath(url) {
- return url.slice(DOCS_SITE.length);
-}
-
-/**
- * Convierte el MDX crudo de una sección en texto indexable y legible: título del primer encabezado,
- * endpoint de su anotación {{ tag, label }} y el cuerpo sin código, etiquetas ni URLs. Conserva los
- * saltos de línea y las tablas para que el extracto se lea.
- */
-export function cleanSection(markdown) {
- const withoutCode = markdown.replace(CODE_BLOCK, '');
- const heading = HEADING.exec(withoutCode)?.[1] ?? '';
- const text = withoutCode
- .replace(HEADING, '')
- .replace(ANNOTATION, '')
- // El name de es el nombre del campo: se busca por él.
- .replace(TAG_WITH_NAME, '$1')
- // El value de un
- es la clave del catálogo (por ejemplo el motivo de cancelación "01").
- .replace(ITEM_VALUE, (_, quoted, plain) => `${quoted ?? plain} `)
- .replace(ANY_TAG, '')
- .replace(LINK, '$1')
- // El tokenizador no corta en el acento grave: `sat.blacklist.69b` indexaría "69b`" y no "69b".
- .replace(INLINE_CODE_MARK, '')
- .replace(LINE_INDENT, '')
- .replace(REPEATED_BLANK_LINES, '\n\n')
- .trim();
- return { title: heading.replace(ANNOTATION, '').trim(), endpoint: endpointOf(heading), text };
-}
-
-/** El documento del índice de una sección; si la sección no tiene encabezado, se titula como su página. */
-export function toDocument({ url, page }, markdown) {
- const { title, endpoint, text } = cleanSection(markdown);
- return { id: url, url, page, title: title || page, endpoint, text };
-}
-
-export async function fetchLlms(origin) {
- return fetchMarkdown(`${origin}/llms.txt`);
-}
-
-/** Descarga las URLs del sitio desde origin, como mucho 8 a la vez; devuelve el Markdown en el mismo orden. */
-export async function downloadMarkdown(origin, urls) {
- const markdown = new Array(urls.length);
- let next = 0;
- let failed = false;
- async function worker() {
- while (!failed && next < urls.length) {
- const index = next++;
- try {
- markdown[index] = await fetchMarkdown(`${origin}${sitePath(urls[index])}`);
- } catch (error) {
- failed = true;
- throw error;
- }
- }
- }
- await Promise.all(Array.from({ length: Math.min(DOWNLOAD_CONCURRENCY, urls.length) }, worker));
- return markdown;
-}
-
-/** El origen sin "/" final; solo se aceptan URLs http(s). */
-export function normalizeOrigin(origin) {
- let url;
- try {
- url = new URL(origin);
- } catch {
- throw new DocsError(`el origen de la documentación no es una URL válida: ${origin}`);
- }
- if (url.protocol !== 'http:' && url.protocol !== 'https:') {
- throw new DocsError(`el origen de la documentación debe ser http(s): ${origin}`);
- }
- return origin.replace(/\/+$/, '');
-}
-
-// Un 200 con HTML es la página del sitio (por ejemplo un 404 servido como página) y no su Markdown.
-async function fetchMarkdown(url) {
- let response;
- let body;
- try {
- response = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
- if (response.status !== 200) {
- await response.body?.cancel();
- throw new DocsError(`${url} respondió HTTP ${response.status}; se esperaba 200.`);
- }
- body = await response.text();
- } catch (error) {
- if (error instanceof DocsError) throw error;
- throw new DocsError(`no se pudo descargar ${url}: ${error.message}`);
- }
- if (body.trimStart().startsWith('<')) throw new DocsError(`${url} devolvió HTML en lugar de Markdown.`);
- return body;
-}
-
-function endpointOf(heading) {
- const annotation = [...heading.matchAll(ANNOTATION)][0]?.[1];
- if (annotation === undefined) return '';
- const tag = ANNOTATION_TAG.exec(annotation)?.[1];
- const label = ANNOTATION_LABEL.exec(annotation)?.[1];
- return tag !== undefined && label !== undefined ? `${tag} ${label}` : '';
-}
-
-function escapeRegExp(value) {
- return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
-}
diff --git a/mcp/scripts/openapi.mjs b/mcp/scripts/openapi.mjs
deleted file mode 100644
index 127caab..0000000
--- a/mcp/scripts/openapi.mjs
+++ /dev/null
@@ -1,258 +0,0 @@
-// Transformación pura del OpenAPI de FiscalAPI a los datos que sirve el MCP: sin red ni disco.
-
-export const MAX_OPERATION_CHARS = 40_000;
-
-const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'];
-// El operationId se vuelve nombre de archivo: solo se aceptan ids que no puedan salir de data/operations.
-const SAFE_OPERATION_ID = /^[A-Za-z][A-Za-z0-9_]*$/;
-const PREFERRED_MEDIA_TYPE = 'application/json';
-const DEFAULT_TAG = 'General';
-
-export class OpenApiError extends Error {}
-
-export function parseOpenApi(text) {
- let doc;
- try {
- doc = JSON.parse(text);
- } catch {
- throw new OpenApiError('la fuente no es JSON válido.');
- }
- if (!isObject(doc) || typeof doc.openapi !== 'string' || !doc.openapi.startsWith('3.') || !isObject(doc.paths)) {
- throw new OpenApiError('la fuente no es un documento OpenAPI 3 (faltan "openapi" o "paths").');
- }
- return doc;
-}
-
-export function extractOperations(doc) {
- const operations = [];
- const seen = new Set();
- for (const [path, pathItem] of Object.entries(doc.paths)) {
- for (const method of HTTP_METHODS) {
- const operation = pathItem[method];
- if (!isObject(operation)) continue;
- const id = operation.operationId;
- if (typeof id !== 'string' || !SAFE_OPERATION_ID.test(id)) {
- throw new OpenApiError(`${method.toUpperCase()} ${path} no tiene un operationId válido (${JSON.stringify(id)}).`);
- }
- if (seen.has(id)) throw new OpenApiError(`el operationId "${id}" está repetido.`);
- seen.add(id);
- operations.push({ id, method: method.toUpperCase(), path, pathItem, operation });
- }
- }
- if (operations.length === 0) throw new OpenApiError('el documento no tiene operaciones.');
- return operations;
-}
-
-export function buildCatalog(operations) {
- return operations
- .map(({ id, method, path, operation }) => ({
- id,
- method,
- path,
- summary: operation.summary ?? '',
- tag: operation.tags?.[0] ?? DEFAULT_TAG,
- }))
- .sort((a, b) => compare(a.tag, b.tag) || compare(a.id, b.id));
-}
-
-/**
- * Resuelve los $ref de la operación y la recorta, en etapas deterministas, hasta que su JSON
- * mida como máximo maxChars. Lanza OpenApiError si ni el recorte más agresivo cabe.
- */
-export function buildOperation(doc, entry, maxChars = MAX_OPERATION_CHARS) {
- const full = new OperationResolver(doc, {}).resolve(entry);
- if (fits(full.value, maxChars)) return full.value;
-
- const withoutExamples = { dropExamples: true };
- let candidate = new OperationResolver(doc, withoutExamples).resolve(entry).value;
- if (fits(candidate, maxChars)) return candidate;
-
- for (let depth = full.maxDepth; depth >= 1; depth--) {
- candidate = new OperationResolver(doc, { ...withoutExamples, descriptionDepth: depth }).resolve(entry).value;
- if (fits(candidate, maxChars)) return candidate;
- }
-
- for (let depth = full.maxDepth; depth >= 0; depth--) {
- const options = { ...withoutExamples, descriptionDepth: 1, refDepth: depth };
- candidate = new OperationResolver(doc, options).resolve(entry).value;
- if (fits(candidate, maxChars)) return candidate;
- }
- throw new OpenApiError(`la operación "${entry.id}" mide más de ${maxChars} caracteres aun recortada.`);
-}
-
-export function serializeOperation(operation) {
- return JSON.stringify(operation);
-}
-
-/**
- * Resuelve una operación con unas opciones de recorte fijas. Profundidad 0 es la raíz del esquema
- * de un parámetro, cuerpo o respuesta; cada nivel de properties, items o additionalProperties suma uno.
- */
-class OperationResolver {
- #doc;
- #dropExamples;
- #descriptionDepth;
- #refDepth;
- #maxDepth = 0;
-
- constructor(doc, { dropExamples = false, descriptionDepth = Infinity, refDepth = Infinity }) {
- this.#doc = doc;
- this.#dropExamples = dropExamples;
- this.#descriptionDepth = descriptionDepth;
- this.#refDepth = refDepth;
- }
-
- resolve({ id, method, path, pathItem, operation }) {
- const parameters = [...(pathItem.parameters ?? []), ...(operation.parameters ?? [])];
- const value = {
- operationId: id,
- method,
- path,
- tag: operation.tags?.[0] ?? DEFAULT_TAG,
- summary: operation.summary ?? '',
- description: operation.description ?? '',
- };
- if (operation.deprecated === true) value.deprecated = true;
- if (parameters.length > 0) value.parameters = parameters.map((p) => this.#parameter(p));
- if (operation.requestBody) value.requestBody = this.#withContent(this.#deref(operation.requestBody));
- if (operation.responses) value.responses = this.#responses(operation.responses);
- const security = operation.security ?? this.#doc.security;
- if (security) {
- value.security = security;
- value.securitySchemes = this.#securitySchemes(security);
- }
- return { value, maxDepth: this.#maxDepth };
- }
-
- #parameter(parameter) {
- const { schema, ...rest } = this.#withoutExamples(this.#deref(parameter));
- return schema === undefined ? rest : { ...rest, schema: this.#schema(schema, 0, []) };
- }
-
- #responses(responses) {
- const result = {};
- for (const [code, response] of Object.entries(responses)) {
- const resolved = this.#withContent(this.#deref(response));
- if (isObject(resolved.headers)) {
- resolved.headers = mapValues(resolved.headers, (header) => this.#parameter(header));
- }
- result[code] = resolved;
- }
- return result;
- }
-
- #withContent(holder) {
- if (!isObject(holder.content)) return { ...holder };
- const content = {};
- for (const [mediaType, media] of Object.entries(collapseMediaTypes(holder.content))) {
- const { schema, ...rest } = this.#withoutExamples(media);
- content[mediaType] = schema === undefined ? rest : { ...rest, schema: this.#schema(schema, 0, []) };
- }
- return { ...holder, content };
- }
-
- #schema(schema, depth, stack) {
- if (!isObject(schema)) return schema;
- this.#maxDepth = Math.max(this.#maxDepth, depth);
-
- if (typeof schema.$ref === 'string') {
- const { $ref, ...siblings } = schema;
- const name = refName($ref);
- if (stack.includes($ref)) return { type: 'object', 'x-recursivo': name };
- if (depth > this.#refDepth) return { type: 'object', 'x-recortado': name };
- return this.#schema({ ...this.#pointer($ref), ...siblings }, depth, [...stack, $ref]);
- }
-
- const result = {};
- for (const [key, value] of Object.entries(schema)) {
- if (this.#dropExamples && (key === 'example' || key === 'examples')) continue;
- if (key === 'description' && depth >= this.#descriptionDepth) continue;
- result[key] = this.#schemaKeyword(key, value, depth, stack);
- }
- return result;
- }
-
- #schemaKeyword(key, value, depth, stack) {
- switch (key) {
- case 'properties':
- case 'patternProperties':
- return isObject(value) ? mapValues(value, (child) => this.#schema(child, depth + 1, stack)) : value;
- case 'items':
- case 'additionalProperties':
- case 'not':
- return this.#schema(value, depth + 1, stack);
- case 'allOf':
- case 'oneOf':
- case 'anyOf':
- case 'prefixItems':
- return Array.isArray(value) ? value.map((child) => this.#schema(child, depth, stack)) : value;
- default:
- return value;
- }
- }
-
- #securitySchemes(security) {
- const declared = this.#doc.components?.securitySchemes ?? {};
- const names = [...new Set(security.flatMap((requirement) => Object.keys(requirement)))];
- return Object.fromEntries(names.filter((name) => declared[name]).map((name) => [name, declared[name]]));
- }
-
- #withoutExamples(object) {
- if (!this.#dropExamples) return { ...object };
- const { example: _example, examples: _examples, ...rest } = object;
- return rest;
- }
-
- // Parámetros, cuerpos y respuestas también pueden venir por $ref a components.
- #deref(object, seen = new Set()) {
- if (!isObject(object) || typeof object.$ref !== 'string') return object;
- if (seen.has(object.$ref)) throw new OpenApiError(`$ref circular fuera de un esquema: ${object.$ref}.`);
- seen.add(object.$ref);
- return this.#deref(this.#pointer(object.$ref), seen);
- }
-
- #pointer(ref) {
- if (!ref.startsWith('#/')) throw new OpenApiError(`solo se admiten $ref locales: ${ref}.`);
- let target = this.#doc;
- for (const segment of ref.slice(2).split('/')) {
- const key = decodeURIComponent(segment).replaceAll('~1', '/').replaceAll('~0', '~');
- target = isObject(target) ? target[key] : undefined;
- }
- if (target === undefined) throw new OpenApiError(`$ref sin destino: ${ref}.`);
- return target;
- }
-}
-
-// ASP.NET publica el mismo esquema en application/json, text/json, text/plain y application/*+json.
-// Se conserva application/json y solo los tipos cuyo contenido es distinto.
-function collapseMediaTypes(content) {
- const preferred = content[PREFERRED_MEDIA_TYPE];
- if (preferred === undefined) return content;
- const preferredJson = JSON.stringify(preferred);
- return Object.fromEntries(
- Object.entries(content).filter(
- ([mediaType, media]) => mediaType === PREFERRED_MEDIA_TYPE || JSON.stringify(media) !== preferredJson,
- ),
- );
-}
-
-function fits(operation, maxChars) {
- return serializeOperation(operation).length <= maxChars;
-}
-
-function refName(ref) {
- return ref.slice(ref.lastIndexOf('/') + 1);
-}
-
-function mapValues(object, fn) {
- return Object.fromEntries(Object.entries(object).map(([key, value]) => [key, fn(value)]));
-}
-
-function isObject(value) {
- return typeof value === 'object' && value !== null && !Array.isArray(value);
-}
-
-// Orden por unidades de código: no depende del locale ni de la versión de ICU.
-function compare(a, b) {
- return a < b ? -1 : a > b ? 1 : 0;
-}
diff --git a/mcp/scripts/snapshot-docs.mjs b/mcp/scripts/snapshot-docs.mjs
deleted file mode 100644
index 123bfc9..0000000
--- a/mcp/scripts/snapshot-docs.mjs
+++ /dev/null
@@ -1,30 +0,0 @@
-// Guarda una copia de la documentación para las pruebas: llms.txt y todas las .md que enlaza.
-//
-// Uso: node scripts/snapshot-docs.mjs
-// origen: de dónde descargar, por ejemplo http://localhost:3999 o https://docs.fiscalapi.com.
-// salida: { "llms": "", "files": { "/.md": "" } } comprimido con gzip.
-import { writeFile } from 'node:fs/promises';
-import { gzipSync } from 'node:zlib';
-
-import { DocsError, downloadMarkdown, fetchLlms, listMarkdownUrls, normalizeOrigin, sitePath } from './docs.mjs';
-
-async function main() {
- const [originArg, outFile] = process.argv.slice(2);
- if (originArg === undefined || outFile === undefined) {
- throw new DocsError('uso: node scripts/snapshot-docs.mjs ');
- }
- const origin = normalizeOrigin(originArg);
- const llms = await fetchLlms(origin);
- const urls = listMarkdownUrls(llms);
- const markdown = await downloadMarkdown(origin, urls);
- const files = Object.fromEntries(urls.map((url, i) => [sitePath(url), markdown[i]]));
- await writeFile(outFile, gzipSync(JSON.stringify({ llms, files })));
- console.error(`snapshot-docs: ${urls.length} archivos .md de ${origin} en ${outFile}`);
-}
-
-try {
- await main();
-} catch (error) {
- console.error(`snapshot-docs: ${error instanceof DocsError ? error.message : (error?.stack ?? String(error))}`);
- process.exitCode = 1;
-}
diff --git a/mcp/src/catalog.ts b/mcp/src/catalog.ts
deleted file mode 100644
index 95d0b1d..0000000
--- a/mcp/src/catalog.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-import { readFileSync } from 'node:fs';
-import { readFile } from 'node:fs/promises';
-import { fileURLToPath } from 'node:url';
-
-export interface CatalogEntry {
- readonly id: string;
- readonly method: string;
- readonly path: string;
- readonly summary: string;
- readonly tag: string;
-}
-
-const MAX_SUGGESTIONS = 5;
-
-/**
- * Catálogo de operaciones generado por scripts/build-data.mjs. Solo lee archivos de data/ cuyo
- * nombre sale del propio catálogo, nunca de la entrada del usuario.
- */
-export class OperationCatalog {
- readonly #entries: readonly CatalogEntry[];
- readonly #byId: ReadonlyMap;
- readonly #operationsDir: URL;
-
- private constructor(entries: readonly CatalogEntry[], dataDir: URL) {
- this.#entries = entries;
- this.#byId = new Map(entries.map((entry) => [entry.id, entry]));
- this.#operationsDir = new URL('operations/', dataDir);
- }
-
- /** dataDir debe terminar en "/" para que las rutas relativas se resuelvan dentro de él. */
- static load(dataDir: URL): OperationCatalog {
- const catalogFile = new URL('catalog.json', dataDir);
- let entries: CatalogEntry[];
- try {
- entries = JSON.parse(readFileSync(catalogFile, 'utf8')) as CatalogEntry[];
- } catch (error) {
- throw new Error(`No se pudo leer el catálogo de operaciones (${fileURLToPath(catalogFile)}). Corre npm run build:data.`, {
- cause: error,
- });
- }
- return new OperationCatalog(entries, dataDir);
- }
-
- get tags(): string[] {
- return [...new Set(this.#entries.map((entry) => entry.tag))];
- }
-
- /** Las operaciones en el orden del catálogo (tag, operationId); con tag, solo las de ese tag sin distinguir mayúsculas. */
- list(tag?: string): CatalogEntry[] {
- if (tag === undefined) return [...this.#entries];
- const wanted = tag.toLowerCase();
- return this.#entries.filter((entry) => entry.tag.toLowerCase() === wanted);
- }
-
- /** El JSON de la operación, o undefined si el id no está en el catálogo. */
- async readOperation(operationId: string): Promise {
- const entry = this.#byId.get(operationId);
- if (entry === undefined) return undefined;
- return readFile(new URL(`${entry.id}.json`, this.#operationsDir), 'utf8');
- }
-
- /**
- * Hasta 5 ids, sin distinguir mayúsculas: primero los que empiezan con la entrada y luego los que
- * la contienen. Si ninguno coincide (un error de dedo como "createInvoce"), los que comparten con
- * ella el prefijo más largo, de al menos 3 letras.
- */
- suggest(input: string): string[] {
- const needle = input.trim().toLowerCase();
- if (needle === '') return [];
- const ids = this.#entries.map((entry) => entry.id);
- const prefixed = ids.filter((id) => id.toLowerCase().startsWith(needle));
- const containing = ids.filter((id) => !id.toLowerCase().startsWith(needle) && id.toLowerCase().includes(needle));
- const matches = [...prefixed, ...containing];
- if (matches.length > 0) return matches.slice(0, MAX_SUGGESTIONS);
- return closestByCommonPrefix(ids, needle).slice(0, MAX_SUGGESTIONS);
- }
-}
-
-const MIN_COMMON_PREFIX = 3;
-
-// Solo los ids con el prefijo común más largo; entre ellos, primero el de longitud más parecida a la entrada.
-function closestByCommonPrefix(ids: readonly string[], needle: string): string[] {
- const candidates = ids.map((id) => ({
- id,
- common: commonPrefixLength(id.toLowerCase(), needle),
- lengthGap: Math.abs(id.length - needle.length),
- }));
- const longest = Math.max(0, ...candidates.map((candidate) => candidate.common));
- if (longest < MIN_COMMON_PREFIX) return [];
- return candidates
- .filter((candidate) => candidate.common === longest)
- .sort((a, b) => a.lengthGap - b.lengthGap)
- .map((candidate) => candidate.id);
-}
-
-function commonPrefixLength(a: string, b: string): number {
- let length = 0;
- while (length < a.length && length < b.length && a[length] === b[length]) length++;
- return length;
-}
diff --git a/mcp/src/docs.ts b/mcp/src/docs.ts
deleted file mode 100644
index a7f00a9..0000000
--- a/mcp/src/docs.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-import { readFileSync } from 'node:fs';
-import { fileURLToPath } from 'node:url';
-
-import MiniSearch from 'minisearch';
-
-import { DOCS_INDEX_OPTIONS, processTerm } from './docs-search.js';
-import type { DocsDocument } from './docs-search.js';
-
-export interface DocsResult {
- readonly title: string;
- readonly page: string;
- readonly url: string;
- readonly excerpt: string;
-}
-
-// Unos 750 tokens por resultado: 10 resultados caben en el límite de unos 10k tokens por respuesta.
-export const MAX_EXCERPT_WORDS = 300;
-export const MAX_EXCERPT_CHARS = 3_000;
-// Contexto antes de la primera coincidencia, para que se lea de qué habla.
-const WORDS_BEFORE_MATCH = 40;
-const ELLIPSIS = '…';
-const WORD = /\S+/g;
-
-type StoredFields = Pick;
-
-/** Índice léxico de docs.fiscalapi.com generado por scripts/build-data.mjs; se consulta sin red. */
-export class DocsIndex {
- readonly #index: MiniSearch;
-
- private constructor(index: MiniSearch) {
- this.#index = index;
- }
-
- /** dataDir debe terminar en "/" para que las rutas relativas se resuelvan dentro de él. */
- static load(dataDir: URL): DocsIndex {
- const indexFile = new URL('docs-index.json', dataDir);
- try {
- return new DocsIndex(MiniSearch.loadJSON(readFileSync(indexFile, 'utf8'), DOCS_INDEX_OPTIONS));
- } catch (error) {
- throw new Error(`No se pudo leer el índice de la documentación (${fileURLToPath(indexFile)}). Corre npm run build:data.`, {
- cause: error,
- });
- }
- }
-
- /** Los limit resultados más relevantes, cada uno con un extracto alrededor de su primera coincidencia. */
- search(query: string, limit: number): DocsResult[] {
- return this.#index
- .search(query)
- .slice(0, limit)
- .map((result) => {
- const { title, page, url, text } = result as unknown as StoredFields;
- return { title, page, url, excerpt: excerpt(text, result.terms, result.queryTerms) };
- });
- }
-}
-
-const tokenize: (text: string) => string[] = MiniSearch.getDefault('tokenize');
-
-/**
- * Hasta 300 palabras y 3,000 caracteres del texto, desde unas 40 palabras antes de la primera que
- * coincidió con la consulta. Así "CFDI40147" muestra la fila de esa tabla y no el inicio de la tabla.
- */
-function excerpt(text: string, matchedTerms: readonly string[], queryTerms: readonly string[]): string {
- const words = [...text.matchAll(WORD)];
- if (words.length === 0) return '';
- const first = firstWord(words, firstMatch(words, matchedTerms, queryTerms));
- const last = Math.min(words.length, first + MAX_EXCERPT_WORDS) - 1;
- const budget = MAX_EXCERPT_CHARS - 2 * ELLIPSIS.length;
- let body = text.slice(words[first]!.index, endOf(words[last]!));
- let cutAtEnd = last < words.length - 1;
- if (body.length > budget) {
- body = cutAtWhitespace(body, budget);
- cutAtEnd = true;
- }
- return `${first > 0 ? ELLIPSIS : ''}${body}${cutAtEnd ? ELLIPSIS : ''}`;
-}
-
-// Primero una coincidencia exacta con la consulta: el fuzzy hace que "CFDI40147" también coincida con
-// CFDI40101, que está antes en la misma tabla. Si no hay exacta, la primera coincidencia cualquiera, y
-// si la coincidencia fue solo en el título, el inicio del texto.
-function firstMatch(words: readonly RegExpExecArray[], matchedTerms: readonly string[], queryTerms: readonly string[]): number {
- const exact = new Set(matchedTerms.filter((term) => queryTerms.includes(term)));
- const any = new Set(matchedTerms);
- for (const terms of [exact, any]) {
- const index = words.findIndex((word) => tokenize(word[0]).some((token) => terms.has(processTerm(token) ?? '')));
- if (index >= 0) return index;
- }
- return 0;
-}
-
-// Retrocede hasta 40 palabras, pero sin que la coincidencia quede fuera del límite de caracteres
-// (una fila de tabla sin espacios podría medir más que todo el extracto).
-function firstWord(words: readonly RegExpExecArray[], match: number): number {
- const matchEnd = endOf(words[match]!);
- let first = Math.max(0, match - WORDS_BEFORE_MATCH);
- while (first < match && matchEnd - words[first]!.index > MAX_EXCERPT_CHARS / 2) first++;
- return first;
-}
-
-function endOf(word: RegExpExecArray): number {
- return word.index + word[0].length;
-}
-
-function cutAtWhitespace(value: string, maxChars: number): string {
- const cut = value.slice(0, maxChars);
- const lastSpace = cut.search(/\s\S*$/);
- return (lastSpace > 0 ? cut.slice(0, lastSpace) : cut).trimEnd();
-}
diff --git a/mcp/src/index.ts b/mcp/src/index.ts
deleted file mode 100644
index e8ba817..0000000
--- a/mcp/src/index.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-#!/usr/bin/env node
-import { readFileSync } from 'node:fs';
-
-import { serveStdio } from '@modelcontextprotocol/server/stdio';
-
-import { OperationCatalog } from './catalog.js';
-import { DocsIndex } from './docs.js';
-import { createServer } from './server.js';
-
-// stdout es el canal del protocolo: todo diagnóstico va a stderr.
-const packageRoot = new URL('../', import.meta.url);
-
-let catalog: OperationCatalog;
-let docs: DocsIndex;
-let version: string;
-try {
- version = (JSON.parse(readFileSync(new URL('package.json', packageRoot), 'utf8')) as { version: string }).version;
- const dataDir = new URL('data/', packageRoot);
- catalog = OperationCatalog.load(dataDir);
- docs = DocsIndex.load(dataDir);
-} catch (error) {
- console.error(`fiscalapi-mcp: ${error instanceof Error ? error.message : String(error)}`);
- process.exit(1);
-}
-
-const handle = serveStdio(() => createServer(catalog, docs, version), {
- onerror: (error) => console.error(`fiscalapi-mcp: ${error.message}`),
-});
-
-for (const signal of ['SIGINT', 'SIGTERM'] as const) {
- process.on(signal, () => {
- void handle.close().finally(() => process.exit(0));
- });
-}
diff --git a/mcp/src/server.ts b/mcp/src/server.ts
deleted file mode 100644
index 1c744c0..0000000
--- a/mcp/src/server.ts
+++ /dev/null
@@ -1,147 +0,0 @@
-import { McpServer } from '@modelcontextprotocol/server';
-import type { CallToolResult } from '@modelcontextprotocol/server';
-import * as z from 'zod/v4';
-
-import type { CatalogEntry, OperationCatalog } from './catalog.js';
-import type { DocsIndex, DocsResult } from './docs.js';
-
-export const SERVER_NAME = 'fiscalapi';
-
-// Las herramientas solo leen archivos del paquete: no llaman a la API ni a ningún otro servicio.
-const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false } as const;
-const MAX_ECHOED_INPUT = 100;
-const MAX_QUERY_CHARS = 200;
-const MAX_DOCS_RESULTS = 10;
-const DEFAULT_DOCS_RESULTS = 5;
-
-export function createServer(catalog: OperationCatalog, docs: DocsIndex, version: string): McpServer {
- const server = new McpServer({ name: SERVER_NAME, version });
-
- server.registerTool(
- 'listar_operaciones',
- {
- title: 'Listar operaciones de FiscalAPI',
- description:
- 'Lista las operaciones de la API REST de FiscalAPI (facturas CFDI 4.0 y complementos, cancelación, descarga masiva del SAT, personas, productos, certificados, webhooks y más), agrupadas por tag: operationId, método, ruta y resumen. Úsala para encontrar el endpoint que necesitas; el siguiente paso es llamar a obtener_operacion con su operationId para ver parámetros, cuerpo y respuestas. No llama a la API ni pide llaves.',
- inputSchema: z.object({
- tag: z
- .string()
- .max(MAX_ECHOED_INPUT)
- .optional()
- .describe('Tag por el que filtrar, sin distinguir mayúsculas (por ejemplo Invoices, People o Products). Omítelo para ver todas.'),
- }),
- annotations: READ_ONLY,
- },
- async ({ tag }) => listOperations(catalog, tag),
- );
-
- server.registerTool(
- 'obtener_operacion',
- {
- title: 'Obtener una operación de FiscalAPI',
- description:
- 'Devuelve en JSON el detalle de una operación de la API de FiscalAPI: método, ruta, descripción con el enlace a su documentación, parámetros, cuerpo y respuestas con los esquemas resueltos. Usa el operationId exacto que da listar_operaciones. No llama a la API ni pide llaves.',
- inputSchema: z.object({
- operationId: z
- .string()
- .min(1)
- .max(MAX_ECHOED_INPUT)
- .describe('operationId exacto de la operación, tal como aparece en listar_operaciones (por ejemplo createInvoice).'),
- }),
- annotations: READ_ONLY,
- },
- async ({ operationId }) => getOperation(catalog, operationId),
- );
-
- server.registerTool(
- 'buscar_docs',
- {
- title: 'Buscar en la documentación de FiscalAPI',
- description:
- 'Busca en las secciones de docs.fiscalapi.com: guías, reglas fiscales del SAT, catálogos, errores del PAC por código (como CFDI40147) y la referencia de cada endpoint. Devuelve de cada resultado el título, la página, la URL .md de la sección y un extracto; para leer la sección completa, descarga esa URL. No llama a la API ni pide llaves.',
- inputSchema: z.object({
- consulta: z
- .string()
- .min(1)
- .max(MAX_QUERY_CHARS)
- .describe('Qué buscar, en español o por código (por ejemplo "cancelar factura", "complemento de pago" o "CFDI40147").'),
- limite: z
- .number()
- .int()
- .min(1)
- .max(MAX_DOCS_RESULTS)
- .optional()
- .describe(`Número máximo de resultados, de 1 a ${MAX_DOCS_RESULTS}. Por omisión ${DEFAULT_DOCS_RESULTS}.`),
- }),
- annotations: READ_ONLY,
- },
- async ({ consulta, limite }) => searchDocs(docs, consulta, limite ?? DEFAULT_DOCS_RESULTS),
- );
-
- return server;
-}
-
-function listOperations(catalog: OperationCatalog, tag: string | undefined): CallToolResult {
- const entries = catalog.list(tag);
- if (entries.length === 0) {
- return error(`No hay operaciones con el tag "${tag}". Tags disponibles: ${catalog.tags.join(', ')}.`);
- }
- const header =
- `FiscalAPI: ${entries.length} operaciones. Para ver parámetros, cuerpo y respuestas de una, ` +
- 'llama a obtener_operacion con su operationId.';
- return text([header, ...groupByTag(entries)].join('\n'));
-}
-
-async function getOperation(catalog: OperationCatalog, operationId: string): Promise {
- const operation = await catalog.readOperation(operationId);
- if (operation !== undefined) return text(operation);
-
- const suggestions = catalog.suggest(operationId);
- const hint = suggestions.length > 0 ? `¿Quisiste decir ${suggestions.join(', ')}? ` : '';
- return error(`No existe la operación "${operationId}". ${hint}Usa listar_operaciones para ver los operationId disponibles.`);
-}
-
-function searchDocs(docs: DocsIndex, query: string, limit: number): CallToolResult {
- const results = docs.search(query, limit);
- const echoed = clip(query);
- // Sin resultados no es un error de la llamada: el agente debe reformular, no reintentar igual.
- if (results.length === 0) {
- return text(
- `FiscalAPI docs: no hay resultados para "${echoed}". Prueba con otras palabras, o usa listar_operaciones para ver los endpoints de la API.`,
- );
- }
- const count = results.length === 1 ? '1 resultado' : `${results.length} resultados`;
- const blocks = results.map((result) => `## ${heading(result)}\n${result.url}\n\n${result.excerpt}`);
- return text([`FiscalAPI docs: ${count} para "${echoed}".`, ...blocks].join('\n\n'));
-}
-
-// Una página sin secciones se indexa entera y su título es el de la página: no se repite.
-function heading({ title, page }: DocsResult): string {
- return title === page ? title : `${title} (${page})`;
-}
-
-// La consulta admite más caracteres de los que vale la pena repetir en la respuesta.
-function clip(value: string): string {
- return value.length > MAX_ECHOED_INPUT ? `${value.slice(0, MAX_ECHOED_INPUT)}…` : value;
-}
-
-function groupByTag(entries: readonly CatalogEntry[]): string[] {
- const lines: string[] = [];
- let currentTag: string | undefined;
- for (const entry of entries) {
- if (entry.tag !== currentTag) {
- currentTag = entry.tag;
- lines.push('', `## ${entry.tag}`);
- }
- lines.push(`${entry.id} ${entry.method} ${entry.path} ${entry.summary}`);
- }
- return lines;
-}
-
-function text(value: string): CallToolResult {
- return { content: [{ type: 'text', text: value }] };
-}
-
-function error(message: string): CallToolResult {
- return { content: [{ type: 'text', text: message }], isError: true };
-}
diff --git a/mcp/test/build-data.test.ts b/mcp/test/build-data.test.ts
deleted file mode 100644
index 36c4616..0000000
--- a/mcp/test/build-data.test.ts
+++ /dev/null
@@ -1,369 +0,0 @@
-import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
-import { createServer } from 'node:http';
-import type { AddressInfo } from 'node:net';
-import { tmpdir } from 'node:os';
-import path from 'node:path';
-import { fileURLToPath } from 'node:url';
-
-import { afterAll, describe, expect, it } from 'vitest';
-
-import { cleanSection, DocsError, parseLlms } from '../scripts/docs.mjs';
-import {
- buildCatalog,
- buildOperation,
- extractOperations,
- OpenApiError,
- parseOpenApi,
- serializeOperation,
-} from '../scripts/openapi.mjs';
-import { serveDocs } from './docs-server.js';
-import { dataDirUrl } from './helpers.js';
-import { runBuildData } from './run-build-data.js';
-
-const packageDir = fileURLToPath(new URL('../', import.meta.url));
-const openApiFixture = path.join(packageDir, 'test', 'fixtures', 'openapi-v4.json');
-// Límite de RNF-05 escrito aquí a propósito: si alguien sube el del build, la prueba lo detecta.
-const SPEC_MAX_OPERATION_CHARS = 40_000;
-const LONG_TEXT ='Texto largo de prueba para inflar el esquema. '.repeat(8);
-
-// Un cuerpo que resuelto mide cientos de miles de caracteres: obliga a recortar.
-function hugeDoc(): Record {
- const leafProperties = Object.fromEntries(
- Array.from({ length: 30 }, (_, i) => [`campo${i}`, { type: 'string', description: LONG_TEXT, example: LONG_TEXT }]),
- );
- const rootProperties = Object.fromEntries(
- Array.from({ length: 40 }, (_, i) => [`hijo${i}`, { $ref: '#/components/schemas/Hijo' }]),
- );
- return {
- openapi: '3.1.1',
- paths: {
- '/api/v4/grande': {
- post: {
- operationId: 'createGrande',
- tags: ['Pruebas'],
- summary: 'Crear algo grande',
- description: 'Operación enorme. Docs: [grande](https://docs.fiscalapi.com/grande).',
- requestBody: {
- content: {
- 'application/json': { schema: { $ref: '#/components/schemas/Grande' } },
- 'text/json': { schema: { $ref: '#/components/schemas/Grande' } },
- },
- },
- responses: { '200': { description: 'OK' } },
- },
- },
- },
- components: {
- schemas: {
- Grande: {
- type: 'object',
- description: 'Raíz del cuerpo',
- required: ['hijo0'],
- properties: { ...rootProperties, estado: { type: 'string', enum: ['A', 'B'], format: 'x-codigo' } },
- },
- Hijo: { type: ['null', 'object'], required: ['campo0'], properties: leafProperties },
- },
- },
- };
-}
-
-function entryOf(doc: Record, id: string) {
- const entry = extractOperations(doc).find((candidate: { id: string }) => candidate.id === id);
- if (!entry) throw new Error(`Falta ${id} en el documento de prueba.`);
- return entry;
-}
-
-describe('buildOperation', () => {
- it('recorta una operación enorme hasta el límite y conserva la estructura y la documentación', () => {
- const doc = hugeDoc();
-
- const operation = buildOperation(doc, entryOf(doc, 'createGrande'));
- const json = serializeOperation(operation);
-
- expect(json.length).toBeLessThanOrEqual(SPEC_MAX_OPERATION_CHARS);
- expect(operation.description).toContain('https://docs.fiscalapi.com/grande');
- expect(operation.summary).toBe('Crear algo grande');
- expect(json).not.toContain('"example"');
- expect(json).not.toContain('"$ref"');
- const body = operation.requestBody.content['application/json'].schema;
- expect(Object.keys(operation.requestBody.content)).toEqual(['application/json']);
- expect(body.required).toEqual(['hijo0']);
- expect(body.properties.estado).toEqual({ type: 'string', enum: ['A', 'B'], format: 'x-codigo' });
- expect(Object.keys(body.properties)).toHaveLength(41);
- });
-
- it('quita primero ejemplos y descripciones anidadas antes de recortar subárboles', () => {
- const doc = hugeDoc();
- const full = serializeOperation(buildOperation(doc, entryOf(doc, 'createGrande'), Infinity));
-
- // Cabe sin ejemplos ni descripciones, pero no sin ejemplos solamente.
- const operation = buildOperation(doc, entryOf(doc, 'createGrande'), full.length / 3);
- const json = serializeOperation(operation);
-
- expect(json).not.toContain('x-recortado');
- expect(json).not.toContain(LONG_TEXT);
- const body = operation.requestBody.content['application/json'].schema;
- expect(body.description).toBe('Raíz del cuerpo');
- expect(body.properties.hijo0).toMatchObject({ type: ['null', 'object'], required: ['campo0'] });
- expect(body.properties.hijo0.properties.campo29).toEqual({ type: 'string' });
- });
-
- it('como último recurso reemplaza los subárboles por su nombre de esquema', () => {
- const doc = hugeDoc();
-
- const operation = buildOperation(doc, entryOf(doc, 'createGrande'), 4_000);
-
- const body = operation.requestBody.content['application/json'].schema;
- expect(serializeOperation(operation).length).toBeLessThanOrEqual(4_000);
- expect(body.properties.hijo0).toEqual({ type: 'object', 'x-recortado': 'Hijo' });
- expect(body.properties.estado).toEqual({ type: 'string', enum: ['A', 'B'], format: 'x-codigo' });
- });
-
- it('falla si la operación no cabe ni recortada', () => {
- const doc = hugeDoc();
-
- expect(() => buildOperation(doc, entryOf(doc, 'createGrande'), 100)).toThrow(OpenApiError);
- });
-
- it('corta los esquemas recursivos sin ciclar', () => {
- const doc = {
- openapi: '3.1.1',
- paths: {
- '/api/v4/nodos': {
- get: {
- operationId: 'listNodos',
- responses: {
- '200': { description: 'OK', content: { 'application/json': { schema: { $ref: '#/components/schemas/Nodo' } } } },
- },
- },
- },
- },
- components: {
- schemas: { Nodo: { type: 'object', properties: { hijos: { type: 'array', items: { $ref: '#/components/schemas/Nodo' } } } } },
- },
- };
-
- const operation = buildOperation(doc, entryOf(doc, 'listNodos'));
-
- const schema = operation.responses['200'].content['application/json'].schema;
- expect(schema.properties.hijos.items).toEqual({ type: 'object', 'x-recursivo': 'Nodo' });
- });
-});
-
-describe('extractOperations y buildCatalog', () => {
- it('rechaza un operationId que podría salir de data/operations', () => {
- const doc = { openapi: '3.1.1', paths: { '/x': { get: { operationId: '../x', responses: {} } } } };
-
- expect(() => extractOperations(doc)).toThrow(/operationId válido/);
- });
-
- it('ordena el catálogo por tag y luego por operationId', () => {
- const doc = {
- openapi: '3.1.1',
- paths: {
- '/b': { get: { operationId: 'zeta', tags: ['B'], summary: 'z' } },
- '/a': { get: { operationId: 'beta', tags: ['A'], summary: 'b' }, post: { operationId: 'alfa', tags: ['A'], summary: 'a' } },
- },
- };
-
- expect(buildCatalog(extractOperations(doc)).map((entry: { id: string }) => entry.id)).toEqual(['alfa', 'beta', 'zeta']);
- });
-
- it('rechaza JSON que no es OpenAPI 3', () => {
- expect(() => parseOpenApi('{"swagger":"2.0","paths":{}}')).toThrow(OpenApiError);
- expect(() => parseOpenApi('')).toThrow(/no es JSON/);
- });
-});
-
-// Cada prueba arranca un proceso de Node: en Windows, con los otros archivos de prueba corriendo en
-// paralelo, el primero llegó a tardar más que los 5 s por omisión.
-describe('scripts/build-data.mjs', { timeout: 30_000 }, () => {
- const workDir = mkdtempSync(path.join(tmpdir(), 'fiscalapi-mcp-build-'));
- afterAll(() => rmSync(workDir, { recursive: true, force: true }));
-
- const outDir = path.join(workDir, 'data');
-
- function runBuild(source: string, ...args: string[]): Promise<{ code: number | null; stderr: string }> {
- return runBuildData([source, ...args, '--out', outDir]);
- }
-
- it('sale con código 1 si la URL no responde 200', async () => {
- const server = createServer((_, response) => response.writeHead(401).end());
- await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
- const { port } = server.address() as AddressInfo;
- try {
- const { code, stderr } = await runBuild(`http://127.0.0.1:${port}/api/openapi/v4.json`);
-
- expect(code).toBe(1);
- expect(stderr).toContain('respondió HTTP 401; se esperaba 200');
- } finally {
- server.close();
- }
- });
-
- it('sale con código 1 si el archivo no es un OpenAPI', async () => {
- const notOpenApi = path.join(workDir, 'no-openapi.json');
- writeFileSync(notOpenApi, '{"hola":"mundo"}');
-
- const { code, stderr } = await runBuild(notOpenApi);
-
- expect(code).toBe(1);
- expect(stderr).toContain('no es un documento OpenAPI 3');
- });
-
- // Un data/ previo con una marca: si el build fallido lo tocara, la marca desaparecería.
- function seedPreviousData(): string {
- rmSync(outDir, { recursive: true, force: true });
- mkdirSync(outDir, { recursive: true });
- const marker = path.join(outDir, 'previo.txt');
- writeFileSync(marker, 'data/ anterior');
- return marker;
- }
-
- function leftovers(): string[] {
- return readdirSync(workDir).filter((name) => name.startsWith('data.tmp-'));
- }
-
- it.each([
- ['responde 404', { '/pagina/seccion-b.md': undefined }, 'pagina/seccion-b.md respondió HTTP 404; se esperaba 200'],
- ['devuelve HTML', { '/pagina/seccion-b.md': '' }, 'pagina/seccion-b.md devolvió HTML en lugar de Markdown'],
- ])('sale con código 1 y no toca data/ si una sección .md %s', async (_, override, message) => {
- const files: Record = {
- '/pagina/seccion-a.md': '## Sección A\n\nTexto.',
- '/pagina/seccion-b.md': '## Sección B\n\nTexto.',
- ...override,
- };
- const docs = await serveDocs({
- llms: SMALL_LLMS,
- files: Object.fromEntries(Object.entries(files).filter((entry): entry is [string, string] => entry[1] !== undefined)),
- });
- const marker = seedPreviousData();
- try {
- const { code, stderr } = await runBuild(openApiFixture, '--docs-origin', docs.origin);
-
- expect(code).toBe(1);
- expect(stderr).toContain(`build-data: ${docs.origin}/${message}`);
- expect(readFileSync(marker, 'utf8')).toBe('data/ anterior');
- expect(readdirSync(outDir)).toEqual(['previo.txt']);
- expect(leftovers()).toEqual([]);
- } finally {
- await docs.close();
- }
- });
-
- it('sale con código 1 si llms.txt no existe', async () => {
- const docs = await serveDocs({ llms: SMALL_LLMS, files: {} });
- try {
- const { code, stderr } = await runBuild(openApiFixture, '--docs-origin', `${docs.origin}/no-existe`);
-
- expect(code).toBe(1);
- expect(stderr).toContain(`${docs.origin}/no-existe/llms.txt respondió HTTP 404`);
- } finally {
- await docs.close();
- }
- });
-
- it('guarda en source.json el origen, el hash y el número de documentos de la documentación', () => {
- const source = JSON.parse(readFileSync(new URL('source.json', dataDirUrl()), 'utf8'));
-
- expect(source.operations).toBe(100);
- expect(source.docs).toMatchObject({ origin: expect.stringMatching(/^http:\/\/127\.0\.0\.1:\d+$/), documents: 383 });
- expect(source.docs.sha256).toMatch(/^[0-9a-f]{64}$/);
- expect(existsSync(new URL('docs-index.json', dataDirUrl()))).toBe(true);
- });
-});
-
-const SMALL_LLMS = [
- '# FiscalAPI',
- '',
- '- Los endpoints están bajo `/api/v4/`.',
- '- [Página](https://docs.fiscalapi.com/pagina.md): descripción.',
- ' - [Sección A](https://docs.fiscalapi.com/pagina/seccion-a.md)',
- ' - [Sección B](https://docs.fiscalapi.com/pagina/seccion-b.md)',
-].join('\n');
-
-describe('parseLlms', () => {
- it('indexa cada sección con su página y las páginas sin secciones enteras, sin repetir URL', () => {
- const llms = [
- SMALL_LLMS,
- '',
- '## Referencia',
- '',
- '- [Sola](https://docs.fiscalapi.com/sola.md): página sin secciones.',
- '- [Otra](https://docs.fiscalapi.com/otra.md): repite una sección.',
- ' - [Sección A](https://docs.fiscalapi.com/pagina/seccion-a.md)',
- ' - [Propia](https://docs.fiscalapi.com/otra/propia.md)',
- '- [Sola](https://docs.fiscalapi.com/sola.md): la misma página otra vez.',
- '- [Externa](https://example.com/externa.md): de otro sitio.',
- ].join('\n');
-
- expect(parseLlms(llms)).toEqual([
- { url: 'https://docs.fiscalapi.com/pagina/seccion-a.md', page: 'Página' },
- { url: 'https://docs.fiscalapi.com/pagina/seccion-b.md', page: 'Página' },
- { url: 'https://docs.fiscalapi.com/sola.md', page: 'Sola' },
- { url: 'https://docs.fiscalapi.com/otra/propia.md', page: 'Otra' },
- ]);
- });
-
- it('falla si el llms.txt no enlaza ninguna .md del sitio', () => {
- expect(() => parseLlms('')).toThrow(DocsError);
- });
-});
-
-describe('cleanSection', () => {
- const section = [
- "## Cancelar factura {{ tag: 'DELETE', label: '/api/v4/invoices' }}",
- '',
- ' Este endpoint cancela una factura. Revisa los [valores](/modes-of-operation) y `sat.blacklist.69b`.',
- ' ',
- ' ',
- ' Folio fiscal (UUID) de la factura.',
- ' ',
- '',
- '',
- '',
- `
- Comprobante emitido con errores **con** relación
`,
- ' - Certificado CSD
',
- ' ',
- '',
- '```bash',
- '# Esto no es un encabezado',
- 'curl -X DELETE https://test.fiscalapi.com/api/v4/invoices',
- '```',
- '',
- '| Código | Descripción |',
- '| --- | --- |',
- '| CFDI40147 | El campo DomicilioFiscalReceptor |',
- ].join('\n');
-
- it('separa título y endpoint de la anotación', () => {
- const { title, endpoint } = cleanSection(section);
-
- expect(title).toBe('Cancelar factura');
- expect(endpoint).toBe('DELETE /api/v4/invoices');
- });
-
- it('quita código, etiquetas, URLs y sangría; conserva el name y el value de las etiquetas, los saltos de línea y las tablas', () => {
- const { text } = cleanSection(section);
-
- expect(text).toBe(
- [
- 'Este endpoint cancela una factura. Revisa los valores y sat.blacklist.69b.',
- '',
- 'invoiceUuid',
- 'Folio fiscal (UUID) de la factura.',
- '',
- '01 Comprobante emitido con errores **con** relación',
- '0 Certificado CSD',
- '',
- '| Código | Descripción |',
- '| --- | --- |',
- '| CFDI40147 | El campo DomicilioFiscalReceptor |',
- ].join('\n'),
- );
- });
-
- it('una sección sin anotación no tiene endpoint', () => {
- expect(cleanSection('# Webhooks\n\nTexto.')).toEqual({ title: 'Webhooks', endpoint: '', text: 'Texto.' });
- });
-});
diff --git a/mcp/test/docs-server.ts b/mcp/test/docs-server.ts
deleted file mode 100644
index db21940..0000000
--- a/mcp/test/docs-server.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import { readFileSync } from 'node:fs';
-import { createServer } from 'node:http';
-import type { AddressInfo } from 'node:net';
-import { gunzipSync } from 'node:zlib';
-
-/** El formato que escribe scripts/snapshot-docs.mjs: llms.txt y cada .md por su ruta. */
-export interface DocsCorpus {
- readonly llms: string;
- readonly files: Readonly>;
-}
-
-export interface DocsServer {
- readonly origin: string;
- close(): Promise;
-}
-
-const corpusFile = new URL('fixtures/docs-corpus.json.gz', import.meta.url);
-
-export function readDocsCorpus(): DocsCorpus {
- return JSON.parse(gunzipSync(readFileSync(corpusFile)).toString('utf8')) as DocsCorpus;
-}
-
-/** Sirve el corpus en 127.0.0.1 como lo haría docs.fiscalapi.com: /llms.txt, cada ruta .md y 404 para lo demás. */
-export async function serveDocs(corpus: DocsCorpus): Promise {
- const routes = new Map(Object.entries({ ...corpus.files, '/llms.txt': corpus.llms }));
- const server = createServer((request, response) => {
- const body = routes.get(new URL(request.url ?? '/', 'http://localhost').pathname);
- if (body === undefined) {
- response.writeHead(404).end();
- return;
- }
- response.writeHead(200, { 'content-type': 'text/markdown; charset=utf-8' }).end(body);
- });
- await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
- const { port } = server.address() as AddressInfo;
- return {
- origin: `http://127.0.0.1:${port}`,
- close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))),
- };
-}
diff --git a/mcp/test/docs-tool.test.ts b/mcp/test/docs-tool.test.ts
deleted file mode 100644
index 08f0df6..0000000
--- a/mcp/test/docs-tool.test.ts
+++ /dev/null
@@ -1,106 +0,0 @@
-import type { Client } from '@modelcontextprotocol/client';
-import { afterAll, beforeAll, describe, expect, it } from 'vitest';
-
-import { callTool, connectClient, textOf } from './helpers.js';
-
-// Límite duro de RNF-05: unos 10k tokens por respuesta, aun con 10 resultados largos.
-const MAX_RESPONSE_CHARS = 40_000;
-// Cada resultado lleva en su propia línea la URL .md de su sección.
-const RESULT_URL = /^https:\/\/docs\.fiscalapi\.com\/\S+\.md$/gm;
-
-let client: Client;
-let close: () => Promise;
-
-beforeAll(async () => {
- ({ client, close } = await connectClient());
-});
-
-afterAll(async () => {
- await close();
-});
-
-describe('buscar_docs', () => {
- it('aparece en tools/list como herramienta de solo lectura', async () => {
- const { tools } = await client.listTools();
-
- const tool = tools.find((candidate) => candidate.name === 'buscar_docs');
- expect(tool?.title).toBe('Buscar en la documentación de FiscalAPI');
- expect(tool?.annotations).toMatchObject({ readOnlyHint: true, destructiveHint: false, openWorldHint: false });
- expect(tool?.inputSchema.required).toEqual(['consulta']);
- });
-
- it('con un código de error del PAC trae el extracto de la fila de ese código', async () => {
- const result = await callTool(client, 'buscar_docs', { consulta: 'CFDI40147' });
- const text = textOf(result);
-
- expect(result.isError).toBeFalsy();
- expect(text).toMatch(/^FiscalAPI docs: \d+ resultados para "CFDI40147"\./);
- expect(text).toContain('https://docs.fiscalapi.com/errors/cfdi-40/errores-cfdi-4-0.md');
- expect(text).toContain('| CFDI40147 |');
- });
-
- it('respeta limite', async () => {
- const text = textOf(await callTool(client, 'buscar_docs', { consulta: 'factura', limite: 2 }));
-
- expect(text.match(RESULT_URL)).toHaveLength(2);
- });
-
- it('sin limite devuelve 5 resultados', async () => {
- const text = textOf(await callTool(client, 'buscar_docs', { consulta: 'factura' }));
-
- expect(text.match(RESULT_URL)).toHaveLength(5);
- });
-
- it('titula una sección con su página entre paréntesis', async () => {
- const text = textOf(await callTool(client, 'buscar_docs', { consulta: 'cancelar una factura', limite: 1 }));
-
- expect(text).toMatch(/^FiscalAPI docs: 1 resultado para "cancelar una factura"\./);
- expect(text).toMatch(/^## Cancelar factura \(.+\)$/m);
- });
-
- it('no repite la página cuando el resultado es la página entera', async () => {
- const text = textOf(await callTool(client, 'buscar_docs', { consulta: 'catálogos sat', limite: 1 }));
-
- expect(text).toContain('## Catálogos SAT\nhttps://docs.fiscalapi.com/catalogs-info.md\n');
- });
-
- it.each(['factura', 'CFDI40147', 'modelo factura', 'catálogo'])(
- 'con limite 10, "%s" cabe en el límite de tamaño',
- async (consulta) => {
- const text = textOf(await callTool(client, 'buscar_docs', { consulta, limite: 10 }));
-
- expect(text.length).toBeLessThanOrEqual(MAX_RESPONSE_CHARS);
- },
- );
-
- it('sin coincidencias responde sin error y sugiere reformular', async () => {
- const result = await callTool(client, 'buscar_docs', { consulta: 'zzzqqq' });
-
- expect(result.isError).toBeFalsy();
- expect(textOf(result)).toContain('no hay resultados para "zzzqqq"');
- expect(textOf(result)).toContain('listar_operaciones');
- });
-
- it('recorta la consulta que repite en la respuesta', async () => {
- const consulta = `${'a'.repeat(150)} zzzqqq`;
-
- const text = textOf(await callTool(client, 'buscar_docs', { consulta }));
-
- expect(text).toContain(`"${'a'.repeat(100)}…"`);
- expect(text).not.toContain('a'.repeat(101));
- });
-
- it.each([
- ['limite 0', { consulta: 'factura', limite: 0 }],
- ['limite 11', { consulta: 'factura', limite: 11 }],
- ['limite no entero', { consulta: 'factura', limite: 2.5 }],
- ['consulta vacía', { consulta: '' }],
- ['consulta de más de 200 caracteres', { consulta: 'a'.repeat(201) }],
- ['sin consulta', {}],
- ])('rechaza %s', async (_, args) => {
- const result = await callTool(client, 'buscar_docs', args);
-
- expect(result.isError).toBe(true);
- expect(textOf(result)).not.toContain('FiscalAPI docs:');
- });
-});
diff --git a/mcp/test/global-setup.ts b/mcp/test/global-setup.ts
deleted file mode 100644
index d5d3d3a..0000000
--- a/mcp/test/global-setup.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import { mkdtempSync, rmSync } from 'node:fs';
-import { tmpdir } from 'node:os';
-import path from 'node:path';
-import { fileURLToPath, pathToFileURL } from 'node:url';
-
-import type { TestProject } from 'vitest/node';
-
-import { readDocsCorpus, serveDocs } from './docs-server.js';
-import { runBuildData } from './run-build-data.js';
-
-export const packageDir = fileURLToPath(new URL('../', import.meta.url));
-export const fixturePath = path.join(packageDir, 'test', 'fixtures', 'openapi-v4.json');
-
-// Los datos de prueba van a un directorio temporal: npm test nunca pisa el data/ que se publica.
-// La documentación sale del fixture servido en local, así que el build de prueba no usa la red.
-export default async function setup(project: TestProject): Promise<() => void> {
- const workDir = mkdtempSync(path.join(tmpdir(), 'fiscalapi-mcp-'));
- const dataDir = path.join(workDir, 'data');
- const docs = await serveDocs(readDocsCorpus());
- let build: { code: number | null; stderr: string };
- try {
- build = await runBuildData([fixturePath, '--docs-origin', docs.origin, '--out', dataDir]);
- } finally {
- await docs.close();
- }
- if (build.code !== 0) {
- rmSync(workDir, { recursive: true, force: true });
- throw new Error(`build-data falló con el fixture:\n${build.stderr}`);
- }
- project.provide('dataDir', pathToFileURL(dataDir + path.sep).href);
- return () => rmSync(workDir, { recursive: true, force: true });
-}
-
-declare module 'vitest' {
- export interface ProvidedContext {
- dataDir: string;
- }
-}
diff --git a/mcp/test/helpers.ts b/mcp/test/helpers.ts
deleted file mode 100644
index 157c5b1..0000000
--- a/mcp/test/helpers.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import { Client, InMemoryTransport } from '@modelcontextprotocol/client';
-import type { CallToolResult } from '@modelcontextprotocol/client';
-import { inject } from 'vitest';
-
-import { OperationCatalog } from '../src/catalog.js';
-import { DocsIndex } from '../src/docs.js';
-import { createServer } from '../src/server.js';
-
-export function dataDirUrl(): URL {
- return new URL(inject('dataDir'));
-}
-
-/** Cliente MCP en proceso conectado al servidor por el transporte en memoria del SDK. */
-export async function connectClient(): Promise<{ client: Client; close: () => Promise }> {
- const server = createServer(OperationCatalog.load(dataDirUrl()), DocsIndex.load(dataDirUrl()), '0.0.0-test');
- const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
- const client = new Client({ name: 'fiscalapi-mcp-test', version: '1.0.0' });
- await server.connect(serverTransport);
- await client.connect(clientTransport);
- return {
- client,
- close: async () => {
- await client.close();
- await server.close();
- },
- };
-}
-
-export async function callTool(client: Client, name: string, args: Record = {}): Promise {
- return (await client.callTool({ name, arguments: args })) as CallToolResult;
-}
-
-export function textOf(result: CallToolResult): string {
- const [block] = result.content;
- if (block?.type !== 'text') throw new Error('Se esperaba un bloque de texto.');
- return block.text;
-}
diff --git a/mcp/test/offline.test.ts b/mcp/test/offline.test.ts
deleted file mode 100644
index 3ae06de..0000000
--- a/mcp/test/offline.test.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { afterEach, expect, it, vi } from 'vitest';
-
-import { callTool, connectClient, textOf } from './helpers.js';
-
-afterEach(() => {
- vi.unstubAllGlobals();
-});
-
-// El cliente se conecta después de cortar la red: así cargar el catálogo y el índice de la
-// documentación también queda cubierto, no solo las llamadas a las herramientas.
-it('arranca y responde sin red: nunca llama a fetch', async () => {
- const fetch = vi.fn(() => {
- throw new Error('Sin red: el MCP no debe descargar nada');
- });
- vi.stubGlobal('fetch', fetch);
-
- const { client, close } = await connectClient();
- try {
- const { tools } = await client.listTools();
- const operation = await callTool(client, 'obtener_operacion', { operationId: 'createInvoice' });
- const docs = await callTool(client, 'buscar_docs', { consulta: 'cancelar factura' });
-
- expect(tools).toHaveLength(3);
- expect(JSON.parse(textOf(operation)).operationId).toBe('createInvoice');
- expect(docs.isError).toBeFalsy();
- expect(textOf(docs)).toContain('https://docs.fiscalapi.com/');
- expect(fetch).not.toHaveBeenCalled();
- } finally {
- await close();
- }
-});
diff --git a/mcp/test/run-build-data.ts b/mcp/test/run-build-data.ts
deleted file mode 100644
index a2b3c68..0000000
--- a/mcp/test/run-build-data.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import { spawn } from 'node:child_process';
-import { fileURLToPath } from 'node:url';
-
-const packageDir = fileURLToPath(new URL('../', import.meta.url));
-
-/**
- * Corre scripts/build-data.mjs en otro proceso. Es asíncrono a propósito: el servidor de la
- * documentación de prueba vive en este proceso y con spawnSync no podría responder.
- */
-export function runBuildData(args: readonly string[]): Promise<{ code: number | null; stderr: string }> {
- return new Promise((resolve, reject) => {
- const child = spawn(process.execPath, ['scripts/build-data.mjs', ...args], { cwd: packageDir });
- let stderr = '';
- child.stderr.on('data', (chunk) => (stderr += chunk));
- child.on('error', reject);
- child.on('close', (code) => resolve({ code, stderr }));
- });
-}
diff --git a/mcp/test/search-regression.test.ts b/mcp/test/search-regression.test.ts
deleted file mode 100644
index cc0d138..0000000
--- a/mcp/test/search-regression.test.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import { describe, expect, it } from 'vitest';
-
-import { DocsIndex } from '../src/docs.js';
-import { dataDirUrl } from './helpers.js';
-
-const TOP = 3;
-const MIN_HITS = 15;
-
-// Caso conocido: el primer resultado es la página general "Catálogos SAT" (catalogs-info.md), que
-// remite al recurso de catálogos; el endpoint "Buscar en un catálogo" queda lejos porque su texto no
-// dice "producto" ni "clave". No tiene su propio it: solo cuenta para el total.
-const KNOWN_MISS = 'buscar clave de producto sat';
-
-// Consultas reales de un agente con la sección que debe aparecer entre los primeros 3 resultados.
-// "estatus ante el sat" acepta sat-validations/validar: la validación sat.cfdi.status ("Estatus del
-// comprobante") responde esa consulta.
-const CASES: ReadonlyArray = [
- ['cancelar una factura', /\/cancelar-factura\.md$/],
- ['complemento de pago', /crear-factura-de-pago|errores-pagos/],
- ['descargar xml', /obtener-xml-por-id|listar-xmls-por-id|descargar-xml-y-pdf/],
- [KNOWN_MISS, /catalogs\/buscar-en-un-catalogo|productos-y-servicios/],
- ['crear cliente receptor', /recipients\/crear-receptor/],
- ['subir certificado csd', /subir-certificados/],
- ['nómina empleado', /employee-data\/|crear-factura-de-nomina/],
- ['webhook', /\/webhooks\//],
- ['saldo de timbres', /\/stamps\//],
- ['estatus ante el sat', /consultar-estado-de-factura|sat-validations\/validar\.md|los-siete-criterios/],
- ['descarga masiva', /download-info|download-requests\//],
- ['lista negra 69b', /sat-validations/],
- ['factura global', /factura-global|global-invoice/],
- ['enviar factura por correo', /enviar-factura-por-correo/],
- ['CFDI40147', /errores-cfdi-4-0/],
- ['objeto de impuesto', /objeto-imp/],
-];
-
-const docs = DocsIndex.load(dataDirUrl());
-
-function topUrls(query: string): string[] {
- return docs.search(query, TOP).map((result) => result.url);
-}
-
-function hits(query: string, expected: RegExp): boolean {
- return topUrls(query).some((url) => expected.test(url));
-}
-
-describe('buscar_docs: regresión de relevancia', () => {
- it.each(CASES.filter(([query]) => query !== KNOWN_MISS))('"%s" trae la sección esperada en el top 3', (query, expected) => {
- expect(topUrls(query)).toEqual(expect.arrayContaining([expect.stringMatching(expected)]));
- });
-
- it(`acierta al menos ${MIN_HITS} de ${CASES.length} consultas`, () => {
- const total = CASES.filter(([query, expected]) => hits(query, expected)).length;
-
- expect(total).toBeGreaterThanOrEqual(MIN_HITS);
- });
-});
diff --git a/mcp/test/tools.test.ts b/mcp/test/tools.test.ts
deleted file mode 100644
index cb09e44..0000000
--- a/mcp/test/tools.test.ts
+++ /dev/null
@@ -1,138 +0,0 @@
-import { readFileSync } from 'node:fs';
-
-import type { Client } from '@modelcontextprotocol/client';
-import { afterAll, beforeAll, describe, expect, it } from 'vitest';
-
-import { callTool, connectClient, dataDirUrl, textOf } from './helpers.js';
-
-// Límites duros de RNF-05: unos 3k tokens para el catálogo y unos 10k para una operación.
-const MAX_CATALOG_CHARS = 12_000;
-const MAX_OPERATION_CHARS = 40_000;
-const OPERATION_LINE = /^\w+ {2}(GET|POST|PUT|PATCH|DELETE) \/\S* {2}.+$/;
-
-interface CatalogEntry {
- id: string;
- tag: string;
-}
-
-const catalog = JSON.parse(readFileSync(new URL('catalog.json', dataDirUrl()), 'utf8')) as CatalogEntry[];
-
-let client: Client;
-let close: () => Promise;
-
-beforeAll(async () => {
- ({ client, close } = await connectClient());
-});
-
-afterAll(async () => {
- await close();
-});
-
-function operationLines(text: string): string[] {
- return text.split('\n').filter((line) => OPERATION_LINE.test(line));
-}
-
-describe('tools/list', () => {
- it('expone exactamente buscar_docs, listar_operaciones y obtener_operacion con descripciones en español', async () => {
- const { tools } = await client.listTools();
-
- expect(tools.map((tool) => tool.name).sort()).toEqual(['buscar_docs', 'listar_operaciones', 'obtener_operacion']);
- const byName = Object.fromEntries(tools.map((tool) => [tool.name, tool]));
- expect(byName.listar_operaciones?.description).toContain('el siguiente paso es llamar a obtener_operacion');
- expect(byName.obtener_operacion?.description).toContain('Usa el operationId exacto que da listar_operaciones');
- expect(byName.obtener_operacion?.inputSchema.required).toEqual(['operationId']);
- for (const tool of tools) {
- expect(tool.annotations?.readOnlyHint).toBe(true);
- expect(tool.description).not.toMatch(/—/);
- }
- });
-});
-
-describe('listar_operaciones', () => {
- it('sin tag lista las 100 operaciones agrupadas por tag dentro del límite de tamaño', async () => {
- const result = await callTool(client, 'listar_operaciones');
- const text = textOf(result);
-
- expect(result.isError).toBeFalsy();
- expect(operationLines(text)).toHaveLength(100);
- expect(text).toContain('createInvoice POST /api/v4/invoices Emitir (timbrar) una factura CFDI 4.0');
- expect(text).toContain('## Invoices');
- expect(text.length).toBeLessThanOrEqual(MAX_CATALOG_CHARS);
- });
-
- it('con tag filtra sin distinguir mayúsculas', async () => {
- const invoices = catalog.filter((entry) => entry.tag === 'Invoices').map((entry) => entry.id);
-
- const text = textOf(await callTool(client, 'listar_operaciones', { tag: 'invoices' }));
-
- const ids = operationLines(text).map((line) => line.split(' ')[0]);
- expect(ids).toEqual(invoices);
- expect(text).not.toContain('## People');
- });
-
- it('con un tag inexistente devuelve error con los tags disponibles', async () => {
- const result = await callTool(client, 'listar_operaciones', { tag: 'Facturas' });
-
- expect(result.isError).toBe(true);
- expect(textOf(result)).toContain('No hay operaciones con el tag "Facturas"');
- expect(textOf(result)).toContain('Invoices');
- });
-});
-
-describe('obtener_operacion', () => {
- it('devuelve createInvoice con su cuerpo resuelto y dentro del límite de tamaño', async () => {
- const result = await callTool(client, 'obtener_operacion', { operationId: 'createInvoice' });
- const text = textOf(result);
- const operation = JSON.parse(text);
-
- expect(result.isError).toBeFalsy();
- expect(operation).toMatchObject({ operationId: 'createInvoice', method: 'POST', path: '/api/v4/invoices' });
- expect(operation.description).toContain('https://docs.fiscalapi.com/');
- const body = operation.requestBody.content['application/json'].schema;
- expect(body.properties.items.items.properties).toHaveProperty('itemCode');
- expect(text).not.toContain('"$ref"');
- expect(text.length).toBeLessThanOrEqual(MAX_OPERATION_CHARS);
- });
-
- it.each(catalog.map((entry) => entry.id))('%s cabe en el límite y no deja $ref sin resolver', async (operationId) => {
- const result = await callTool(client, 'obtener_operacion', { operationId });
- const text = textOf(result);
-
- expect(result.isError).toBeFalsy();
- expect(JSON.parse(text).operationId).toBe(operationId);
- expect(text).not.toContain('"$ref"');
- expect(text.length).toBeLessThanOrEqual(MAX_OPERATION_CHARS);
- });
-
- it('con un id mal escrito devuelve error y sugiere el correcto', async () => {
- const result = await callTool(client, 'obtener_operacion', { operationId: 'createInvoce' });
-
- expect(result.isError).toBe(true);
- expect(textOf(result)).toContain('No existe la operación "createInvoce"');
- expect(textOf(result)).toContain('¿Quisiste decir createInvoice?');
- });
-
- it('sin parecido suficiente no sugiere nada y remite a listar_operaciones', async () => {
- const result = await callTool(client, 'obtener_operacion', { operationId: 'xyz' });
-
- expect(result.isError).toBe(true);
- expect(textOf(result)).toBe('No existe la operación "xyz". Usa listar_operaciones para ver los operationId disponibles.');
- });
-
- it('sugiere primero los ids que empiezan con lo enviado y luego los que lo contienen', async () => {
- const result = await callTool(client, 'obtener_operacion', { operationId: 'getinvoice' });
-
- expect(result.isError).toBe(true);
- expect(textOf(result)).toContain('¿Quisiste decir getInvoice, getInvoicePdf, getInvoiceStatus, getInvoiceXml?');
- });
-
- it.each(['../package', '../catalog', '..\\catalog', '/etc/passwd'])('rechaza %s sin leer archivos fuera de data/operations', async (operationId) => {
- const result = await callTool(client, 'obtener_operacion', { operationId });
- const text = textOf(result);
-
- expect(result.isError).toBe(true);
- expect(text).toContain('No existe la operación');
- expect(text).not.toContain('"summary"');
- expect(text).not.toContain('@fiscalapi/mcp');
- });
-});
diff --git a/mcp/tsconfig.json b/mcp/tsconfig.json
deleted file mode 100644
index 402c028..0000000
--- a/mcp/tsconfig.json
+++ /dev/null
@@ -1,18 +0,0 @@
-{
- "compilerOptions": {
- "target": "ES2023",
- "lib": ["ES2023"],
- "module": "NodeNext",
- "moduleResolution": "NodeNext",
- "types": ["node"],
- "strict": true,
- "noUncheckedIndexedAccess": true,
- "noImplicitOverride": true,
- "verbatimModuleSyntax": true,
- "erasableSyntaxOnly": true,
- "skipLibCheck": true,
- "rootDir": "src",
- "outDir": "dist"
- },
- "include": ["src"]
-}
diff --git a/scripts/check-skills.mjs b/scripts/check-skills.mjs
deleted file mode 100644
index fece6f9..0000000
--- a/scripts/check-skills.mjs
+++ /dev/null
@@ -1,172 +0,0 @@
-// Revisa lo que publica el plugin antes de que llegue a un integrador:
-// - el frontmatter de cada SKILL.md sigue el estándar de agentskills.io;
-// - cada enlace a docs.fiscalapi.com responde 200 y, si lleva ancla, el ancla existe en la página;
-// - lo mismo para los enlaces "Docs:" de las descripciones del OpenAPI público (/api/openapi/v4.json), que
-// fapi-back escribe a mano y que un título renombrado en fapi-docs rompería sin que nadie lo note.
-// Las skills enlazan las docs en vez de copiarlas (D-03): un enlace roto deja a la skill sin su fuente.
-//
-// Uso: node scripts/check-skills.mjs [carpeta ...] (por omisión, skills y commands)
-// FISCALAPI_DOCS_ORIGIN=http://localhost:3100 revisa los enlaces contra un build local de fapi-docs, para
-// validar secciones que todavía no están publicadas.
-// FISCALAPI_OPENAPI_URL= lee el OpenAPI de un back local o de un archivo, para
-// validar descripciones que todavía no están en producción.
-// Sale con código 1 si hay algún hallazgo.
-
-import { readdir, readFile, stat } from 'node:fs/promises'
-import path from 'node:path'
-
-const DOCS_ORIGIN = 'https://docs.fiscalapi.com'
-const CHECK_ORIGIN = process.env.FISCALAPI_DOCS_ORIGIN ?? DOCS_ORIGIN
-const OPENAPI_SOURCE = process.env.FISCALAPI_OPENAPI_URL ?? 'https://live.fiscalapi.com/api/openapi/v4.json'
-const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']
-const DOCS_URL = /https:\/\/docs\.fiscalapi\.com[^\s)<>"'`\]]*/g
-const SKILL_NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/
-const MAX_NAME = 64
-const MAX_DESCRIPTION = 1024
-const MAX_SKILL_LINES = 500
-
-const roots = process.argv.slice(2).length > 0 ? process.argv.slice(2) : ['skills', 'commands']
-
-async function exists(target) {
- return stat(target).then(() => true, () => false)
-}
-
-async function markdownFiles(dir) {
- const entries = await readdir(dir, { withFileTypes: true })
- const nested = await Promise.all(
- entries.map((entry) => {
- const full = path.join(dir, entry.name)
- if (entry.isDirectory()) return markdownFiles(full)
- return entry.name.endsWith('.md') ? [full] : []
- }),
- )
- return nested.flat()
-}
-
-/** Las claves de una línea del frontmatter YAML (`clave: valor`, con o sin comillas). */
-function frontmatter(source) {
- const match = source.replace(/\r\n/g, '\n').match(/^---\n([\s\S]*?)\n---\n/)
- if (!match) return null
- const fields = {}
- for (const line of match[1].split('\n')) {
- const field = line.match(/^([A-Za-z-]+):\s*(.*)$/)
- if (field) fields[field[1]] = field[2].trim().replace(/^(['"])([\s\S]*)\1$/, '$2')
- }
- return fields
-}
-
-function skillProblems(file, source) {
- const problems = []
- const folder = path.basename(path.dirname(file))
- const fields = frontmatter(source)
- if (!fields) return [`${file}: falta el frontmatter (--- … ---) al inicio`]
-
- const { name, description } = fields
- if (!name) problems.push(`${file}: falta 'name'`)
- else {
- if (name !== folder) problems.push(`${file}: 'name' (${name}) no es el nombre de la carpeta (${folder})`)
- if (!SKILL_NAME.test(name) || name.length > MAX_NAME)
- problems.push(`${file}: 'name' debe ser minúsculas, dígitos y guiones, de ${MAX_NAME} caracteres como máximo`)
- }
- if (!description) problems.push(`${file}: falta 'description'`)
- else if (/^[>|]/.test(description))
- problems.push(`${file}: 'description' va en una sola línea (este lector no interpreta bloques YAML > o |)`)
- else if (description.length > MAX_DESCRIPTION)
- problems.push(`${file}: 'description' tiene ${description.length} caracteres (máximo ${MAX_DESCRIPTION})`)
-
- const lines = source.split('\n').length
- if (lines >= MAX_SKILL_LINES) problems.push(`${file}: tiene ${lines} líneas (debe tener menos de ${MAX_SKILL_LINES})`)
- return problems
-}
-
-/** Las anclas de la página HTML: el `id` que el sitio genera para cada H2 y los `` explícitos. */
-async function pageAnchors(pageUrl, cache) {
- if (!cache.has(pageUrl)) {
- cache.set(
- pageUrl,
- fetch(pageUrl).then(async (response) =>
- response.ok ? new Set([...(await response.text()).matchAll(/\s(?:id|name)="([^"]+)"/g)].map((m) => m[1])) : null,
- ),
- )
- }
- return cache.get(pageUrl)
-}
-
-async function linkProblems(url, anchorsCache) {
- const [published, anchor] = url.split('#')
- const address = published.replace(DOCS_ORIGIN, CHECK_ORIGIN)
- const response = await fetch(address, { method: 'GET', redirect: 'manual' })
- if (response.status !== 200) return [`${url}: responde ${response.status}`]
- if (!anchor) return []
-
- // El ancla vive en la página HTML; la copia .md de una página tiene las mismas secciones.
- const anchors = await pageAnchors(address.replace(/\.md$/, ''), anchorsCache)
- if (!anchors) return [`${url}: la página HTML de su ancla no responde`]
- return anchors.has(anchor) ? [] : [`${url}: el ancla #${anchor} no existe`]
-}
-
-/** Agrega a `urls` cada enlace a las docs de `text`, recordando dónde aparece (`where`). */
-function collectDocsUrls(text, where, urls) {
- for (const [url] of (text ?? '').matchAll(DOCS_URL)) {
- const clean = url.replace(/[.,;:]+$/, '')
- urls.set(clean, [...(urls.get(clean) ?? []), where])
- }
-}
-
-async function readOpenApi(source) {
- if (!/^https?:\/\//.test(source)) return JSON.parse(await readFile(source, 'utf8'))
- const response = await fetch(source)
- if (!response.ok) throw new Error(`${source} responde ${response.status}`)
- return response.json()
-}
-
-/** Los enlaces a las docs de la descripción del documento y de cada operación, por operationId. */
-async function openApiDocsUrls(source) {
- const document = await readOpenApi(source)
- const urls = new Map()
- collectDocsUrls(document.info?.description, 'OpenAPI info.description', urls)
- for (const [route, item] of Object.entries(document.paths ?? {})) {
- for (const method of HTTP_METHODS) {
- const operation = item[method]
- if (operation) collectDocsUrls(operation.description, `OpenAPI ${operation.operationId ?? `${method} ${route}`}`, urls)
- }
- }
- return urls
-}
-
-async function main() {
- const files = []
- for (const root of roots) if (await exists(root)) files.push(...(await markdownFiles(root)))
-
- const problems = []
- const urls = new Map()
- for (const file of files) {
- const source = await readFile(file, 'utf8')
- if (path.basename(file) === 'SKILL.md') problems.push(...skillProblems(file, source))
- collectDocsUrls(source, file, urls)
- }
- const skillUrlCount = urls.size
-
- let openApiUrlCount = 0
- try {
- const openApiUrls = await openApiDocsUrls(OPENAPI_SOURCE)
- openApiUrlCount = openApiUrls.size
- for (const [url, places] of openApiUrls) urls.set(url, [...(urls.get(url) ?? []), ...places])
- } catch (error) {
- problems.push(`${OPENAPI_SOURCE}: no se pudo leer el OpenAPI (${error.message})`)
- }
-
- const anchorsCache = new Map()
- for (const [url, places] of urls) {
- for (const problem of await linkProblems(url, anchorsCache)) problems.push(`${places.join(', ')}: ${problem}`)
- }
-
- console.log(
- `${files.length} archivos con ${skillUrlCount} enlaces y el OpenAPI (${OPENAPI_SOURCE}) con ${openApiUrlCount}: ` +
- `${urls.size} enlaces a ${DOCS_ORIGIN} revisados en ${CHECK_ORIGIN}, ${problems.length} hallazgos`,
- )
- for (const problem of problems) console.log(`- ${problem}`)
- process.exitCode = problems.length > 0 ? 1 : 0
-}
-
-await main()
diff --git a/scripts/check-skills.mts b/scripts/check-skills.mts
new file mode 100644
index 0000000..64f4cf8
--- /dev/null
+++ b/scripts/check-skills.mts
@@ -0,0 +1,225 @@
+import { readdir, readFile, stat } from 'node:fs/promises';
+import path from 'node:path';
+import { parseArgs } from 'node:util';
+
+const DOCS_SITE = 'https://docs.fiscalapi.com';
+const DEFAULT_OPENAPI_SOURCE = 'https://live.fiscalapi.com/api/openapi/v4.json';
+const DEFAULT_ROOTS = ['skills', 'commands'];
+const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'] as const;
+const DOCS_URL = /https:\/\/docs\.fiscalapi\.com[^\s)<>"'`\]]*/g;
+const TRAILING_PUNCTUATION = /[.,;:]+$/;
+const FRONTMATTER = /^---\n([\s\S]*?)\n---\n/;
+const FRONTMATTER_FIELD = /^([A-Za-z-]+):\s*(.*)$/;
+const QUOTED_VALUE = /^(['"])([\s\S]*)\1$/;
+const HTML_ANCHOR = /\s(?:id|name)="([^"]+)"/g;
+const SKILL_NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/;
+const MAX_NAME_LENGTH = 64;
+const MAX_DESCRIPTION_LENGTH = 1024;
+const MAX_SKILL_LINES = 500;
+const REQUEST_TIMEOUT_MS = 30_000;
+
+interface CheckOptions {
+ readonly roots: readonly string[];
+ readonly docsOrigin: string;
+ readonly openApiSource: string;
+}
+
+type LinkSources = Map;
+
+function readOptions(): CheckOptions {
+ const { positionals, values } = parseArgs({
+ allowPositionals: true,
+ options: { 'docs-origin': { type: 'string' }, openapi: { type: 'string' } },
+ });
+ return {
+ roots: positionals.length > 0 ? positionals : DEFAULT_ROOTS,
+ docsOrigin: (values['docs-origin'] ?? DOCS_SITE).replace(/\/+$/, ''),
+ openApiSource: values.openapi ?? DEFAULT_OPENAPI_SOURCE,
+ };
+}
+
+async function exists(target: string): Promise {
+ return stat(target).then(
+ () => true,
+ () => false,
+ );
+}
+
+async function markdownFiles(dir: string): Promise {
+ const entries = await readdir(dir, { withFileTypes: true });
+ const nested = await Promise.all(
+ entries.map((entry) => {
+ const fullPath = path.join(dir, entry.name);
+ if (entry.isDirectory()) return markdownFiles(fullPath);
+ return Promise.resolve(entry.name.endsWith('.md') ? [fullPath] : []);
+ }),
+ );
+ return nested.flat();
+}
+
+function frontmatterFields(source: string): Map | undefined {
+ const block = FRONTMATTER.exec(source.replace(/\r\n/g, '\n'))?.[1];
+ if (block === undefined) return undefined;
+ const fields = new Map();
+ for (const line of block.split('\n')) {
+ const [, key, value] = FRONTMATTER_FIELD.exec(line) ?? [];
+ if (key !== undefined && value !== undefined) fields.set(key, value.trim().replace(QUOTED_VALUE, '$2'));
+ }
+ return fields;
+}
+
+function skillProblems(file: string, source: string): string[] {
+ const fields = frontmatterFields(source);
+ if (fields === undefined) return [`${file}: missing frontmatter (--- ... ---) at the top`];
+ const lineCount = source.split('\n').length;
+ return [
+ ...nameProblems(file, fields.get('name')),
+ ...descriptionProblems(file, fields.get('description')),
+ ...(lineCount >= MAX_SKILL_LINES ? [`${file}: has ${lineCount} lines (must be under ${MAX_SKILL_LINES})`] : []),
+ ];
+}
+
+function nameProblems(file: string, name: string | undefined): string[] {
+ if (name === undefined) return [`${file}: missing 'name'`];
+ const folder = path.basename(path.dirname(file));
+ const problems: string[] = [];
+ if (name !== folder) problems.push(`${file}: 'name' (${name}) differs from its folder (${folder})`);
+ if (!SKILL_NAME.test(name) || name.length > MAX_NAME_LENGTH) {
+ problems.push(`${file}: 'name' must use lowercase letters, digits and hyphens, up to ${MAX_NAME_LENGTH} characters`);
+ }
+ return problems;
+}
+
+function descriptionProblems(file: string, description: string | undefined): string[] {
+ if (description === undefined) return [`${file}: missing 'description'`];
+ if (/^[>|]/.test(description)) return [`${file}: 'description' must be a single line (YAML blocks > or | are not supported)`];
+ if (description.length > MAX_DESCRIPTION_LENGTH) {
+ return [`${file}: 'description' has ${description.length} characters (max ${MAX_DESCRIPTION_LENGTH})`];
+ }
+ return [];
+}
+
+function collectDocsUrls(text: string, location: string, sources: LinkSources): void {
+ for (const [url] of text.matchAll(DOCS_URL)) {
+ const cleanUrl = url.replace(TRAILING_PUNCTUATION, '');
+ sources.set(cleanUrl, [...(sources.get(cleanUrl) ?? []), location]);
+ }
+}
+
+async function readOpenApi(source: string): Promise {
+ if (!/^https?:\/\//.test(source)) return JSON.parse(await readFile(source, 'utf8'));
+ const response = await fetch(source, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
+ if (!response.ok) throw new Error(`${source} responded HTTP ${response.status}`);
+ return response.json();
+}
+
+function errorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error);
+}
+
+function anchorsIn(html: string): ReadonlySet {
+ return new Set([...html.matchAll(HTML_ANCHOR)].flatMap(([, id]) => (id === undefined ? [] : [id])));
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+function stringOrEmpty(value: unknown): string {
+ return typeof value === 'string' ? value : '';
+}
+
+async function openApiDocsUrls(source: string): Promise {
+ const document = await readOpenApi(source);
+ const sources: LinkSources = new Map();
+ if (!isRecord(document)) return sources;
+ const info = document['info'];
+ collectDocsUrls(stringOrEmpty(isRecord(info) ? info['description'] : undefined), 'OpenAPI info.description', sources);
+ const paths = document['paths'];
+ for (const [route, pathItem] of Object.entries(isRecord(paths) ? paths : {})) {
+ if (!isRecord(pathItem)) continue;
+ for (const method of HTTP_METHODS) {
+ const operation = pathItem[method];
+ if (!isRecord(operation)) continue;
+ const operationLabel = stringOrEmpty(operation['operationId']) || `${method} ${route}`;
+ collectDocsUrls(stringOrEmpty(operation['description']), `OpenAPI ${operationLabel}`, sources);
+ }
+ }
+ return sources;
+}
+
+class LinkChecker {
+ readonly #docsOrigin: string;
+ readonly #anchorsByPage = new Map | undefined>>();
+
+ constructor(docsOrigin: string) {
+ this.#docsOrigin = docsOrigin;
+ }
+
+ async problems(url: string): Promise {
+ const [publishedUrl = url, anchor] = url.split('#');
+ const address = publishedUrl.replace(DOCS_SITE, this.#docsOrigin);
+ let response: Response;
+ try {
+ response = await fetch(address, { redirect: 'manual', signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
+ } catch (error) {
+ return [`${url}: cannot be fetched (${errorMessage(error)})`];
+ }
+ await response.body?.cancel();
+ if (response.status !== 200) return [`${url}: responded HTTP ${response.status}`];
+ if (anchor === undefined || anchor === '') return [];
+
+ const anchors = await this.#pageAnchors(address.replace(/\.md$/, ''));
+ if (anchors === undefined) return [`${url}: the HTML page of its anchor does not respond`];
+ return anchors.has(anchor) ? [] : [`${url}: anchor #${anchor} does not exist`];
+ }
+
+ #pageAnchors(pageUrl: string): Promise | undefined> {
+ let anchors = this.#anchorsByPage.get(pageUrl);
+ if (anchors === undefined) {
+ anchors = fetch(pageUrl, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }).then(
+ async (response) => (response.ok ? anchorsIn(await response.text()) : undefined),
+ () => undefined,
+ );
+ this.#anchorsByPage.set(pageUrl, anchors);
+ }
+ return anchors;
+ }
+}
+
+async function checkSkills({ roots, docsOrigin, openApiSource }: CheckOptions): Promise {
+ const files: string[] = [];
+ for (const root of roots) if (await exists(root)) files.push(...(await markdownFiles(root)));
+
+ const problems: string[] = [];
+ const sources: LinkSources = new Map();
+ for (const file of files) {
+ const source = await readFile(file, 'utf8');
+ if (path.basename(file) === 'SKILL.md') problems.push(...skillProblems(file, source));
+ collectDocsUrls(source, file, sources);
+ }
+ const skillLinkCount = sources.size;
+
+ let openApiLinkCount = 0;
+ try {
+ const openApiSources = await openApiDocsUrls(openApiSource);
+ openApiLinkCount = openApiSources.size;
+ for (const [url, locations] of openApiSources) sources.set(url, [...(sources.get(url) ?? []), ...locations]);
+ } catch (error) {
+ problems.push(`${openApiSource}: cannot read the OpenAPI (${errorMessage(error)})`);
+ }
+
+ const linkChecker = new LinkChecker(docsOrigin);
+ for (const [url, locations] of sources) {
+ for (const problem of await linkChecker.problems(url)) problems.push(`${locations.join(', ')}: ${problem}`);
+ }
+
+ console.log(
+ `${files.length} files with ${skillLinkCount} links and the OpenAPI with ${openApiLinkCount}: ` +
+ `${sources.size} links to ${DOCS_SITE} checked against ${docsOrigin}, ${problems.length} problems`,
+ );
+ for (const problem of problems) console.log(`- ${problem}`);
+ return problems.length;
+}
+
+process.exitCode = (await checkSkills(readOptions())) > 0 ? 1 : 0;
diff --git a/skills/fiscalapi-carta-porte/SKILL.md b/skills/fiscalapi-carta-porte/SKILL.md
index 5c1ada6..e66cbc3 100644
--- a/skills/fiscalapi-carta-porte/SKILL.md
+++ b/skills/fiscalapi-carta-porte/SKILL.md
@@ -1,42 +1,44 @@
---
name: fiscalapi-carta-porte
-description: Emite con FiscalAPI un CFDI con complemento Carta Porte 3.1 por autotransporte, de traslado (tipo T) o de ingreso por el flete (tipo I). Decide moneda, receptor y conceptos según el tipo, arma ubicaciones de origen y destino con domicilio y distancias, mercancías con su clave, peso y material peligroso, el autotransporte (permiso SCT, vehículo, seguros y remolques) y las figuras de transporte, y evita los rechazos CP más comunes. Úsala cuando vayas a timbrar una carta porte o a transportar mercancías por carretera con CFDI. Para un error CP que ya devolvió la API, usa fiscalapi-diagnostico.
+description: Timbra con FiscalAPI un CFDI/Factura de carta porte (complemento Carta Porte 3.1, autotransporte), de traslado (tipo T) o de ingreso por el flete (tipo I), con moneda, receptor y conceptos según el tipo, ubicaciones con domicilio y distancias, mercancías, autotransporte y figuras de transporte, sin los rechazos CP más comunes. Úsala cuando vayas a timbrar una carta porte o a amparar con CFDI el transporte de mercancías por carretera. No la uses para diagnosticar un error CP que ya devolvió la API (usa `fiscalapi-diagnostico`).
---
-# Carta Porte 3.1 por autotransporte
+# CFDI/Factura de carta porte (complemento Carta Porte 3.1, autotransporte)
-Antes de escribir el cuerpo, lee [Crear factura de carta porte](https://docs.fiscalapi.com/invoices-by-value/crear-factura-de-carta-porte.md) (por referencias: [esta sección](https://docs.fiscalapi.com/invoices-by-reference/crear-factura-de-carta-porte.md)) y toma su ejemplo como base. El ambiente, las llaves y el SDK están en la skill `fiscalapi-integracion`. Esta skill cubre autotransporte; marítimo, aéreo y ferroviario no.
+El CFDI/Factura de carta porte ampara el transporte de mercancías con el complemento Carta Porte 3.1. Es de traslado (tipo `T`) cuando mueves mercancía propia y de ingreso (tipo `I`) cuando cobras el flete. Esta skill cubre autotransporte; marítimo, aéreo y ferroviario no.
-## 1. Traslado (T) o ingreso (I)
+Antes de escribir el cuerpo, lee [Crear factura de carta porte](https://docs.fiscalapi.com/invoices-by-value/crear-factura-de-carta-porte.md) (por referencias: [esta sección](https://docs.fiscalapi.com/invoices-by-reference/crear-factura-de-carta-porte.md)) y toma su ejemplo como base. El ambiente, la API key y el SDK están en la skill `fiscalapi-integracion`.
+
+## 1. El comprobante: traslado (`T`) o ingreso (`I`)
| | Traslado `T` | Ingreso `I` |
|---|---|---|
-| Cuándo | Mueves mercancía propia; no cobras el flete | Cobras el servicio de transporte a un cliente |
+| Cuándo | Mueves mercancía propia; no cobras el flete | Cobras el servicio de transporte al receptor |
| `currencyCode` | `XXX` (CP103) | La del cobro, por ejemplo `MXN` (CP104) |
-| Receptor | El mismo emisor (CP107) | El cliente |
-| Uso CFDI | FiscalAPI fija `S01` | El del cliente |
+| Receptor | El mismo emisor (CP107) | Quien paga el flete |
+| Uso CFDI | La API fija `S01` | El del receptor |
| Forma y método de pago | No llevan | Los del cobro |
| Conceptos (`items`) | Obligatorios; `taxObjectCode` `01` | Clave de servicio de transporte, por ejemplo `78101800` (CP109) |
-En un traslado, FiscalAPI deja subtotal y total en 0 aunque los conceptos tengan importe. FiscalAPI no valida la moneda ni que el receptor sea el emisor: si te equivocas, rechaza el PAC.
+En un traslado, la API deja subtotal y total en 0 aunque los conceptos tengan importe. Usa la moneda y el receptor de la tabla: en un traslado, `XXX` y el mismo RFC como emisor y receptor; si no, el PAC rechaza (CP103, CP107).
## 2. `complement.cartaPorte`
-FiscalAPI genera solos el `IdCCP`, el peso bruto total y el número total de mercancías: no los mandes. Los nombres de los campos son los del ejemplo de las docs; un campo mal escrito se ignora sin error, así que cópialos de ahí.
+La API genera el `IdCCP`, el peso bruto total y el número total de mercancías: no los mandes. Copia los nombres de los campos exactamente como aparecen en el ejemplo de las docs: un nombre distinto deja ese dato fuera del XML.
- **Raíz:** `transpInternacId` (`No` para nacional, con acento en `Sí`), `unidadPesoId` ([unidad de peso](https://docs.fiscalapi.com/catalogs-info/carta-porte-31/clave-unidad-peso.md), por ejemplo `KGM`) y `totalDistRec`, la suma de las distancias de los destinos (CP125, CP126).
- **Ubicaciones** (al menos un origen y un destino, CP130): `tipoUbicacion` (`Origen` o `Destino`), `idUbicacion` (`OR` o `DE` más 6 dígitos), RFC y nombre del remitente o destinatario, `fechaHoraSalidaLlegada` y, **solo en los destinos**, `distanciaRecorrida` (CP141).
-- **Domicilio de cada ubicación:** `estadoId` (clave de 3 letras del SAT, por ejemplo `CMX`, `JAL`, `COA`), `paisId` `MEX` y `codigoPostalId`; colonia, municipio y localidad deben corresponder a ese código postal y estado (CP143 a CP147). FiscalAPI no lo valida.
+- **Domicilio de cada ubicación:** `estadoId` (clave de 3 letras del SAT, por ejemplo `CMX`, `JAL`, `COA`), `paisId` `MEX` y `codigoPostalId`; colonia, municipio y localidad deben corresponder a ese código postal y estado; si no, el PAC rechaza (CP143 a CP147).
- **Mercancías:** `bienesTranspId` ([clave de producto de carta porte](https://docs.fiscalapi.com/catalogs-info/carta-porte-31/clave-prod-serv-cp.md)), `descripcion`, `cantidad`, `claveUnidadId`, `pesoEnKg` (hasta 3 decimales) y `materialPeligrosoId` (`Sí` o `No`, según lo que la clave permita, CP155). Con material peligroso, `cveMaterialPeligrosoId`, `embalajeId` y el seguro de medio ambiente son obligatorios (CP182).
- **Autotransporte:** `permSCTId` ([tipo de permiso](https://docs.fiscalapi.com/catalogs-info/carta-porte-31/tipo-permiso.md)), `numPermisoSCT`, `configVehicularId` ([configuración vehicular](https://docs.fiscalapi.com/catalogs-info/carta-porte-31/config-autotransporte.md)), `pesoBrutoVehicular`, `placaVM` (5 a 7 caracteres sin guiones ni espacios), `anioModeloVM`, `aseguraRespCivil` y `polizaRespCivil`. Los `remolques` (máximo 2, con `subTipoRemId` y `placa`) van o no según la configuración vehicular (CP184): un tractocamión los exige, un camión unitario no.
- **Figuras de transporte:** al menos el operador (`tipoFiguraId` `01`) con `rfcFigura`, `nombreFigura` y `numLicencia` (CP193, CP194).
## 3. Catálogos
-Los catálogos de carta porte se consultan con la API de catálogos ([tabla de nombres](https://docs.fiscalapi.com/catalogs/catalogos-de-carta-porte-3-1.md)): `SatClaveUnidadPeso`, `SatMaterialPeligroso`, `SatTipoEmbalaje`, `SatTipoPermiso`, `SatConfigAutotransporte`, `SatSubTipoRem`, `SatFiguraTransporte`, `SatColonia`, `SatMunicipio` y `SatLocalidad`. Ten en cuenta sus límites:
+Los catálogos de carta porte se consultan con la API de catálogos ([tabla de nombres](https://docs.fiscalapi.com/catalogs/catalogos-de-carta-porte-3-1.md)): `SatClaveUnidadPeso`, `SatMaterialPeligroso`, `SatTipoEmbalaje`, `SatTipoPermiso`, `SatConfigAutotransporte`, `SatSubTipoRem`, `SatFiguraTransporte`, `SatColonia`, `SatMunicipio` y `SatLocalidad`. Para los domicilios:
-- `SatStates` usa claves numéricas, no las de 3 letras que pide `estadoId`.
-- No hay catálogo de códigos postales, y colonia, municipio y localidad no traen su código postal: la coherencia entre ellos no se puede comprobar con la API. Toma esos datos de una fuente confiable (el domicilio fiscal real) y no los adivines.
+- `estadoId` lleva la clave de 3 letras de `c_Estado`; consúltala en [Estados](https://docs.fiscalapi.com/catalogs-info/nomina-12/estados.md) (`SatPayrollStates`) y no uses las claves numéricas de `SatStates`.
+- Toma el código postal, la colonia, el municipio y la localidad del domicilio real de cada ubicación, sin adivinarlos, y comprueba que correspondan entre sí con los catálogos `c_CodigoPostal` y `c_Colonia` del SAT.
## 4. Antes de timbrar
@@ -45,4 +47,4 @@ Los catálogos de carta porte se consultan con la API de catálogos ([tabla de n
3. Domicilios con estado de 3 letras y colonia, municipio, localidad y código postal congruentes.
4. Remolques según la configuración vehicular y un operador con licencia.
-Si el PAC rechaza, busca el código en [Errores Carta Porte 3.1](https://docs.fiscalapi.com/errors/carta-porte-31/errores-carta-porte-3-1.md) o usa la skill `fiscalapi-diagnostico`. Para consultar el estatus o cancelar, usa `fiscalapi-ciclo-de-vida`.
+Si el PAC rechaza, busca el código en [Errores Carta Porte 3.1](https://docs.fiscalapi.com/errors/carta-porte-31/errores-carta-porte-3-1.md) o usa la skill `fiscalapi-diagnostico`. Para cancelar, consultar el estatus ante el SAT o descargar el XML y el PDF, usa la skill `fiscalapi-ciclo-de-vida`.
diff --git a/skills/fiscalapi-ciclo-de-vida/SKILL.md b/skills/fiscalapi-ciclo-de-vida/SKILL.md
index 022fecb..4ee640d 100644
--- a/skills/fiscalapi-ciclo-de-vida/SKILL.md
+++ b/skills/fiscalapi-ciclo-de-vida/SKILL.md
@@ -1,25 +1,25 @@
---
name: fiscalapi-ciclo-de-vida
-description: Maneja lo que pasa con una factura CFDI de FiscalAPI después de timbrarla. Cubre la cancelación ante el SAT (motivos 01-04, UUID sustituto y aceptación del receptor), el estatus ante el SAT, la descarga del XML y del PDF, el envío por correo y los webhooks (registro, eventos, verificación de la firma, reintentos y pruebas en local con fiscalapi listen). Úsala cuando vayas a cancelar, consultar, descargar o enviar una factura ya emitida, o a recibir eventos de FiscalAPI en tu sistema. Para emitir usa fiscalapi-ingreso-egreso o fiscalapi-pagos; para revisar una integración completa, el comando revisar-integracion.
+description: Gestiona un CFDI de FiscalAPI después de timbrarlo. Cubre la cancelación ante el SAT (motivos 01-04, UUID sustituto y aceptación del receptor), el estatus ante el SAT, la descarga del XML y el PDF, el envío por correo y los webhooks (registro, eventos, firma, reintentos y pruebas en local con `fiscalapi listen`). Úsala cuando vayas a cancelar, consultar, descargar o enviar una factura ya emitida, o a recibir sus eventos en tu sistema. No la uses para emitir (usa `fiscalapi-ingreso-egreso` o `fiscalapi-pagos`) ni para revisar una integración completa (usa el comando `revisar-integracion`).
---
# Después de timbrar
-Cada operación tiene su sección en las docs (por valores; cambia `invoices-by-value` por `invoices-by-reference` si emitiste por referencias). Léela antes de escribir el cuerpo. El ambiente, las llaves y el SDK están en la skill `fiscalapi-integracion`.
+Cada operación tiene su sección en las docs (por valores; cambia `invoices-by-value` por `invoices-by-reference` si emitiste por referencias). Léela antes de escribir el cuerpo. El ambiente, la API key y el SDK están en la skill `fiscalapi-integracion`.
-## 1. Cancelar
+## 1. Cancelación
`DELETE /api/v4/invoices` ([Cancelar factura](https://docs.fiscalapi.com/invoices-by-value/cancelar-factura.md)).
- **Por referencias:** `id` de la factura en FiscalAPI. Firma con el CSD vigente del emisor guardado; una factura emitida por valores no se cancela por referencias.
-- **Por valores:** `invoiceUuid` (el UUID, no el `id`), `tin` del emisor y `taxCredentials` con su `.cer` y su `.key` en base64.
+- **Por valores:** `invoiceUuid` (el UUID, no el `id`), `tin` del emisor y `taxCredentials` con el Certificado CSD (.cer) y la Llave privada CSD (.key) en base64.
- `cancellationReasonCode` ([motivos](https://docs.fiscalapi.com/catalogs-info/cfdi-40/motivos-de-cancelacion.md)):
- `01` comprobante emitido con errores con relación: exige `replacementUuid`, el UUID de la factura que la sustituye. Emite primero la sustituta.
- `02` comprobante emitido con errores sin relación.
- `03` no se llevó a cabo la operación.
- `04` operación nominativa relacionada en una factura global.
-**La respuesta no confirma la cancelación.** Que el PAC acepte la solicitud (201 o 202 en `invoiceUuids`) solo significa que llegó al SAT: la factura queda en "Cancelando". FiscalAPI consulta al SAT en segundo plano y la deja en "Cancelado" o la regresa a "Vigente" si el receptor la rechaza o no es cancelable. Si el SAT pide la aceptación del receptor, este tiene 3 días hábiles para responder. Entérate del resultado con los webhooks `invoice.canceled` e `invoice.cancellation_rejected`, o consultando el estatus. No marques la factura como cancelada en tu sistema con la respuesta del DELETE.
+**La respuesta no confirma la cancelación.** Que el PAC acepte la solicitud (201 o 202 en `invoiceUuids`) solo significa que llegó al SAT: la factura queda en "Cancelando". El estatus se actualiza después: pasa a "Cancelado", o regresa a "Vigente" si el receptor rechaza la cancelación o la factura no es cancelable. Si el SAT pide la aceptación del receptor, este tiene un plazo para responder ([modelo de respuesta](https://docs.fiscalapi.com/invoices-by-value/modelo-de-respuesta-a-la-consulta-del-estado-de-una-factura.md)). Entérate del resultado con los webhooks `invoice.canceled` e `invoice.cancellation_rejected`, o consultando el estatus. No marques la factura como cancelada en tu sistema con la respuesta del DELETE.
## 2. Estatus ante el SAT
@@ -37,13 +37,13 @@ Los archivos llegan en base64 (`base64File`) con `fileName`. Decodifícalos ante
## 4. Webhooks
-- **Registro:** `POST /api/v4/webhooks` con `url` (https, no localhost), `enabledEvents` (o `*`) y, opcional, `personIds` ([Registrar un webhook](https://docs.fiscalapi.com/webhooks-info/registrar-un-webhook.md)). La respuesta trae el `secret` (`whsec_...`): guárdalo como secreto; con la API key no se vuelve a ver salvo al rotarlo.
+- **Registro:** `POST /api/v4/webhooks` con `url` (https, no localhost), `enabledEvents` (o `*`) y, opcional, `personIds` ([Registrar un webhook](https://docs.fiscalapi.com/webhooks-info/registrar-un-webhook.md)). La respuesta trae el `secret` (`whsec_...`): guárdalo como secreto al recibirlo; con la API key solo se vuelve a obtener al rotar el secreto del webhook.
- **Eventos:** `invoice.cancellation_requested`, `invoice.canceled`, `invoice.cancellation_rejected`, `download_request.completed`, `download_request.abandoned` y `download_rule.suspended` ([Tipos de evento](https://docs.fiscalapi.com/webhooks-info/tipos-de-evento.md)). Un evento de una factura emitida por valores no trae persona: solo lo reciben los webhooks sin filtro `personIds`.
-- **Verifica la firma de cada entrega** antes de procesarla ([Verificar la firma](https://docs.fiscalapi.com/webhooks-info/verificar-la-firma.md)): usa el helper del SDK (`Webhook.verify` y equivalentes) sobre el **cuerpo crudo** de la petición, sin parsearlo antes. La firma es Standard Webhooks (cabeceras `webhook-id`, `webhook-timestamp` y `webhook-signature`, HMAC-SHA256) y se rechaza con más de 5 minutos de diferencia.
-- **Entrega al menos una vez y sin orden garantizado:** deduplica por `webhook-id`, responde 2xx rápido, procesa después, y consulta el recurso (`relatedObject`) para conocer su estado actual ([Reintentos](https://docs.fiscalapi.com/webhooks-info/reintentos.md)). Si no respondes 2xx, FiscalAPI reintenta hasta 10 veces en unas 75 horas.
-- **Rotación:** al rotar el secreto, el anterior sigue firmando 24 horas y cada entrega lleva las dos firmas ([Rotar el secreto](https://docs.fiscalapi.com/webhooks-info/rotar-el-secreto.md)).
+- **Verifica la firma de cada entrega** antes de procesarla: usa el helper del SDK (`Webhook.verify` y equivalentes) sobre el **cuerpo crudo** de la petición, sin parsearlo antes. La firma sigue la especificación Standard Webhooks (cabeceras `webhook-id`, `webhook-timestamp` y `webhook-signature`, HMAC-SHA256), y el helper rechaza una entrega cuyo `webhook-timestamp` queda fuera de la tolerancia ([Verificar la firma](https://docs.fiscalapi.com/webhooks-info/verificar-la-firma.md)).
+- **Entrega al menos una vez y sin orden garantizado:** deduplica por `webhook-id`, responde 2xx rápido, procesa después, y consulta el recurso (`relatedObject`) para conocer su estado actual. Si no respondes 2xx, FiscalAPI reintenta la entrega según su calendario de [Reintentos](https://docs.fiscalapi.com/webhooks-info/reintentos.md).
+- **Rotación:** al rotar el secreto, el anterior sigue firmando durante un periodo de transición y cada entrega lleva las dos firmas; pasa al helper los dos secretos mientras dure ([Rotar el secreto](https://docs.fiscalapi.com/webhooks-info/rotar-el-secreto.md)).
- **En local:** `fiscalapi listen --forward-to http://localhost:/` recibe los eventos de tu cuenta de pruebas y `fiscalapi trigger ` dispara uno ([Recibir eventos en tu servidor local](https://docs.fiscalapi.com/webhooks-info/local-testing/recibir-eventos-en-tu-servidor-local.md)). La escucha tiene su propio secreto.
## 5. Fechas
-Manda siempre `X-TIME-ZONE`. Sin él, FiscalAPI usa `America/Mexico_City` para leer y escribir las fechas sin zona, y tu sistema puede interpretarlas en otra. El sobre de los eventos de webhook va siempre en UTC con `Z`.
+Manda siempre `X-TIME-ZONE`. Sin él, FiscalAPI usa `America/Mexico_City` para leer y escribir las fechas sin zona, y tu sistema puede interpretarlas en otra. Las fechas de la estructura del evento de webhook van siempre en UTC con `Z`.
diff --git a/skills/fiscalapi-comercio-exterior/SKILL.md b/skills/fiscalapi-comercio-exterior/SKILL.md
index ab4d095..3e45dd0 100644
--- a/skills/fiscalapi-comercio-exterior/SKILL.md
+++ b/skills/fiscalapi-comercio-exterior/SKILL.md
@@ -1,18 +1,20 @@
---
name: fiscalapi-comercio-exterior
-description: Emite con FiscalAPI un CFDI de exportación definitiva con complemento Comercio Exterior 2.0. Decide exportación 02, moneda y tipo de cambio, el tipo de cambio USD del DOF, el receptor extranjero (XEXX010101000, NumRegIdTrib, residencia fiscal), la clave de pedimento A1, el Incoterm, los domicilios, y las mercancías con fracción arancelaria, unidad y cantidad de aduana y valor en dólares ligadas a cada concepto. También evita los rechazos CCE más comunes. Úsala cuando vayas a facturar una venta al extranjero o una exportación de mercancías. Para un error CCE que ya devolvió la API, usa fiscalapi-diagnostico.
+description: Timbra con FiscalAPI un CFDI/Factura de comercio exterior (complemento Comercio Exterior 2.0, exportación definitiva) con exportación 02, moneda y tipo de cambio USD del DOF, receptor extranjero, clave de pedimento A1, Incoterm, domicilios y mercancías con fracción arancelaria, unidad de aduana y valor en dólares por concepto, sin los rechazos CCE más comunes. Úsala cuando vayas a facturar una venta al extranjero o una exportación de mercancías. No la uses para diagnosticar un error CCE que ya devolvió la API (usa `fiscalapi-diagnostico`).
---
-# Comercio Exterior 2.0 (exportación definitiva)
+# CFDI/Factura de comercio exterior (complemento Comercio Exterior 2.0, exportación definitiva)
-Antes de escribir el cuerpo, lee [Crear factura de comercio exterior](https://docs.fiscalapi.com/invoices-by-value/crear-factura-de-comercio-exterior.md) (por referencias: [esta sección](https://docs.fiscalapi.com/invoices-by-reference/crear-factura-de-comercio-exterior.md)). **No copies la mercancía de su ejemplo cURL**: la factura es de 400 USD y la mercancía declara 23.47 en `valorDolares`, que no cuadra. El ambiente, las llaves y el SDK están en la skill `fiscalapi-integracion`.
+El CFDI/Factura de comercio exterior es una factura de ingreso con el complemento Comercio Exterior 2.0 que ampara la exportación definitiva de mercancías a un receptor en el extranjero. El `valorDolares` de cada mercancía se calcula con el importe de su concepto (sección 3).
+
+Antes de escribir el cuerpo, lee [Crear factura de comercio exterior](https://docs.fiscalapi.com/invoices-by-value/crear-factura-de-comercio-exterior.md) (por referencias: [esta sección](https://docs.fiscalapi.com/invoices-by-reference/crear-factura-de-comercio-exterior.md)). El ambiente, la API key y el SDK están en la skill `fiscalapi-integracion`.
## 1. El comprobante
-- `typeCode` `I`, `exportCode` `02` (exportación definitiva) y `complement.comercioExterior`. Sin el complemento, `02` lo rechaza el PAC (CCE104). FiscalAPI no exige el `02`: mándalo tú.
-- **Moneda:** la de la venta. Con una moneda extranjera manda siempre `exchangeRate` (por ejemplo `currencyCode` `USD` y `exchangeRate` 17.1995): si lo omites, FiscalAPI escribe `TipoCambio` 1.
+- `typeCode` `I`, `exportCode` `02` (exportación definitiva) y `complement.comercioExterior`. Manda siempre el `02` junto con el complemento: con `02` y sin complemento, el PAC rechaza (CCE104).
+- **Moneda:** la de la venta. Con una moneda extranjera manda siempre `exchangeRate` (por ejemplo `currencyCode` `USD` y `exchangeRate` 17.1995): si lo omites, el CFDI queda con `TipoCambio` 1.
- **Receptor extranjero:** `tin` `XEXX010101000`, `legalName` real, `foreignTin` (su número de identificación fiscal; con Estados Unidos o Canadá, 9 dígitos), `countryId` (país de residencia, por ejemplo `USA`), `taxRegimeCode` `616`, `cfdiUseCode` `S01` y `zipCode` igual a `expeditionZipCode` (CFDI40149, CFDI40159). Sin `foreignTin` el PAC rechaza (CCE133).
-- **Cada concepto** necesita `itemSku`: se escribe como `NoIdentificacion` y es la llave que liga el concepto con su mercancía en el complemento (CCE151, CCE152). Por referencias, el `NoIdentificacion` es el `id` del producto.
+- **Cada concepto** necesita `itemSku`: se escribe como `NoIdentificacion` y debe ser igual al `noIdentificacion` de su mercancía en el complemento (CCE151, CCE152). Por referencias, el `NoIdentificacion` es el `id` del producto.
## 2. `complement.comercioExterior`
@@ -24,23 +26,23 @@ Antes de escribir el cuerpo, lee [Crear factura de comercio exterior](https://do
- `mercancias[]`, una por concepto:
- `noIdentificacion`: igual al `itemSku` del concepto.
- `fraccionArancelariaId`: la fracción vigente de la tarifa ([catálogos](https://docs.fiscalapi.com/catalogs/catalogos-de-comercio-exterior-2-0.md)).
- - `unidadAduanaId`: la unidad de medida de la tarifa (UMT) de esa fracción (CCE161, CCE167). El catálogo de la API no trae la UMT de cada fracción: tómala de la tarifa oficial o del agente aduanal.
+ - `unidadAduanaId`: la unidad de medida de la tarifa (UMT) de esa fracción (CCE161, CCE167). Toma la UMT de cada fracción de la tarifa oficial (TIGIE) o de tu agente aduanal.
- `cantidadAduana` y `valorUnitarioAduana`: van juntas con la unidad (CCE165, CCE166).
- `valorDolares`: el importe del concepto en dólares (sección 3).
-FiscalAPI calcula solo `TotalUSD` y no valida la congruencia entre conceptos y mercancías: la valida el PAC.
+La API calcula `TotalUSD`. Cada mercancía debe corresponder a su concepto en `noIdentificacion` y en valor; si no, el PAC rechaza con los códigos de las secciones 1 y 3.
## 3. Tipo de cambio y valor en dólares
-- `tipoCambioUSD` es el tipo de cambio que publica el Banco de México en el DOF el día anterior a la fecha de la factura (o el de adquisición de la moneda); si no coincide, el PAC rechaza (CCE121). FiscalAPI no lo consulta: obtenlo del DOF o de Banxico en tu sistema.
+- `tipoCambioUSD` es el tipo de cambio que publica el Banco de México en el DOF el día anterior a la fecha de la factura (o el de adquisición de la moneda); si no coincide, el PAC rechaza (CCE121). Obtén el valor del DOF o de Banxico en tu sistema y mándalo en cada factura.
- `valorDolares` de cada mercancía (CCE158, CCE172):
- factura en USD: el importe del concepto;
- - factura en MXN: importe ÷ `tipoCambioUSD`;
- - otra moneda: importe × `exchangeRate` ÷ `tipoCambioUSD`.
+ - factura en MXN: importe entre `tipoCambioUSD`;
+ - otra moneda: importe por `exchangeRate` entre `tipoCambioUSD`.
## 4. Catálogos
-Por la API de catálogos: `SatClavePedimento`, `SatINCOTERM`, `SatFraccionArancelaria`, `SatUnidadAduana`, `SatMotivoTraslado`, `SatTipoOperacion` y `SatCountries`. El nombre del catálogo distingue mayúsculas. No hay catálogo de códigos postales, y la fracción no trae su unidad: valida esos datos con su fuente oficial.
+Por la API de catálogos: `SatClavePedimento`, `SatINCOTERM`, `SatFraccionArancelaria`, `SatUnidadAduana`, `SatMotivoTraslado`, `SatTipoOperacion` y `SatCountries`. El nombre del catálogo distingue mayúsculas. Toma el código postal, la colonia, la localidad y el municipio del emisor de su domicilio fiscal real y comprueba que correspondan entre sí con los catálogos `c_CodigoPostal` y `c_Colonia` del SAT. La UMT de cada fracción sale de la tarifa oficial (sección 2).
## 5. Antes de timbrar
@@ -49,4 +51,4 @@ Por la API de catálogos: `SatClavePedimento`, `SatINCOTERM`, `SatFraccionArance
3. Receptor `XEXX010101000` con `foreignTin`, `countryId`, `616`, `S01` y el código postal de expedición.
4. Cada concepto con `itemSku` igual al `noIdentificacion` de su mercancía, y `valorDolares` calculado con la sección 3.
-Si el PAC rechaza, busca el código en [Errores Comercio Exterior 2.0](https://docs.fiscalapi.com/errors/comercio-exterior-20/errores-comercio-exterior-2-0.md) o usa la skill `fiscalapi-diagnostico`. Para consultar el estatus o cancelar, usa `fiscalapi-ciclo-de-vida`.
+Si el PAC rechaza, busca el código en [Errores Comercio Exterior 2.0](https://docs.fiscalapi.com/errors/comercio-exterior-20/errores-comercio-exterior-2-0.md) o usa la skill `fiscalapi-diagnostico`. Para cancelar, consultar el estatus ante el SAT o descargar el XML y el PDF, usa la skill `fiscalapi-ciclo-de-vida`.
diff --git a/skills/fiscalapi-diagnostico/SKILL.md b/skills/fiscalapi-diagnostico/SKILL.md
index 174f829..7001e4c 100644
--- a/skills/fiscalapi-diagnostico/SKILL.md
+++ b/skills/fiscalapi-diagnostico/SKILL.md
@@ -9,15 +9,15 @@ Toda respuesta de FiscalAPI es un `ApiResponse` con `succeeded`, `message`, `det
Lee la respuesta completa antes de proponer un cambio. No inventes la causa: la tabla o la sección enlazada es la fuente.
-## Árbol de decisión
+## 1. Árbol de decisión
-1. **¿`data` es una lista de fallas con `propertyName`?** Es una **validación de FiscalAPI**: la solicitud no llegó al PAC y no se cobró timbre.
+1. **¿`data` es una lista de fallas con `propertyName`?** Es una **validación de FiscalAPI**: la solicitud no llegó al PAC y no consume timbre.
- `propertyName` es la ruta del campo en el request (por ejemplo, `Items[0].ItemTaxes[0].TaxCode`). Corrige ese campo; no te guíes por el texto.
- Modelo de cada falla: [ValidationFailure](https://docs.fiscalapi.com/api-response.md#validationfailure).
- - Mensajes propios del timbrado: [Mensajes de validación del timbrado](https://docs.fiscalapi.com/errors.md#mensajes-de-validacion-del-timbrado).
- - Decimales, longitudes y claves de catálogo: [Decimales](https://docs.fiscalapi.com/errors.md#decimales-de-importes-cantidades-y-tasas), [Valores que no caben en la factura](https://docs.fiscalapi.com/errors.md#valores-que-no-caben-en-la-factura) y [Claves de catálogo del SAT](https://docs.fiscalapi.com/errors.md#claves-de-catalogo-del-sat).
+ - Mensajes propios del timbrado: [Mensajes de validación del timbrado](https://docs.fiscalapi.com/errors/mensajes-de-validacion-del-timbrado.md).
+ - Decimales, longitudes y claves de catálogo: [Decimales](https://docs.fiscalapi.com/errors/decimales-de-importes-cantidades-y-tasas.md), [Valores que no caben en la factura](https://docs.fiscalapi.com/errors/valores-que-no-caben-en-la-factura.md) y [Claves de catálogo del SAT](https://docs.fiscalapi.com/errors/claves-de-catalogo-del-sat.md).
-2. **¿`message` empieza con un código seguido de ` - ` y `data` es `null`?** Es un **rechazo del PAC o del SAT**: no se timbró y el timbre se reembolsó. El código es lo que va antes de ` - `; `details` trae el detalle del PAC. Busca la fila del código en su tabla:
+2. **¿`message` empieza con un código seguido de ` - ` y `data` es `null`?** Es un **rechazo del PAC o del SAT**: no se timbró y no consume timbre. El código es lo que va antes de ` - `; `details` trae el detalle del PAC. Busca la fila del código en su tabla:
| El código empieza con | Tabla |
|---|---|
@@ -27,21 +27,21 @@ Lee la respuesta completa antes de proponer un cambio. No inventes la causa: la
| `CP` | [Errores Carta Porte 3.1](https://docs.fiscalapi.com/errors/carta-porte-31.md) |
| `CCE` | [Errores Comercio Exterior 2.0](https://docs.fiscalapi.com/errors/comercio-exterior-20.md) |
- - Si la tabla no tiene el código, o es un rechazo de cancelación (por ejemplo, `CACFDI33`), la causa es el texto de `message` y `details`. Revisa [Cancelar factura](https://docs.fiscalapi.com/invoices-by-value#cancelar-factura) si fallaba una cancelación.
+ - Si la tabla no tiene el código, o es un rechazo de cancelación (por ejemplo, `CACFDI33`), la causa es el texto de `message` y `details`. Revisa [Cancelar factura](https://docs.fiscalapi.com/invoices-by-value/cancelar-factura.md) si fallaba una cancelación.
- Corrige el dato y vuelve a timbrar: un rechazo es definitivo para esa solicitud.
-3. **¿Es un 5xx?** Es un **error del servidor**: no se resuelve cambiando los datos. Ver [Errores del servidor](https://docs.fiscalapi.com/errors.md#errores-del-servidor).
- - Un 500 al timbrar puede ser un **resultado incierto**: el CFDI pudo quedar timbrado. **No repitas la factura de inmediato**: búscala con [Listar facturas](https://docs.fiscalapi.com/invoices-by-value#listar-facturas) y, si no aparece, escribe a soporte con el `traceIdentifier`.
- - Un 5xx con un código de rechazo en `message` sigue la regla del paso 2.
+3. **¿Es un 5xx?** Es un **error del servidor**: no se resuelve cambiando los datos. Ver [Errores del servidor](https://docs.fiscalapi.com/errors/errores-del-servidor.md).
+ - Un 5xx o un timeout al timbrar es un **resultado incierto**: el CFDI pudo quedar timbrado. **No lo reintentes**: búscalo con [Listar facturas](https://docs.fiscalapi.com/invoices-by-value/listar-facturas.md) y, si no aparece, escribe a soporte con el `traceIdentifier` antes de volver a emitir.
+ - Si el 5xx trae en `message` un código seguido de ` - `, no es un resultado incierto sino un rechazo del PAC o del SAT: aplica el paso 2.
4. **¿Otro 4xx?** Lee `details`:
- - 404: [Recurso no encontrado](https://docs.fiscalapi.com/errors.md#recurso-no-encontrado).
- - 400 sin fallas de validación o 409: las restricciones de los datos y la concurrencia están en [Errores del servidor](https://docs.fiscalapi.com/errors.md#errores-del-servidor).
- - Certificados y llaves privadas: [Certificados y llaves privadas](https://docs.fiscalapi.com/errors.md#certificados-y-llaves-privadas).
+ - 404: [Recurso no encontrado](https://docs.fiscalapi.com/errors/recurso-no-encontrado.md).
+ - Un 400 sin lista de fallas en `data` es una restricción de los datos (registro duplicado, relacionado inexistente, dato obligatorio faltante, longitud o formato inválido), y un 409 es un conflicto de concurrencia. El motivo va en `details` y la tabla está en [Restricciones de los datos y concurrencia](https://docs.fiscalapi.com/errors/errores-del-servidor.md). Ante un 409, vuelve a consultar el registro y repite la solicitud.
+ - Certificados y llaves privadas: [Certificados y llaves privadas](https://docs.fiscalapi.com/errors/certificados-y-llaves-privadas.md).
-## Cómo responder
+## 2. Cómo responder
- Di de qué tipo es el error (validación, rechazo del PAC, error del servidor u otro 4xx) y por qué.
- Cita el campo (`propertyName`) o el código, y la fila o sección enlazada que explica la causa.
-- Propón el cambio concreto en el código del integrador.
-- Nunca pidas ni escribas una API key de producción: las llaves van en variables de entorno.
+- Propón el cambio concreto en el código del proyecto.
+- Nunca pidas ni escribas una API key de producción: la API key va en variables de entorno.
diff --git a/skills/fiscalapi-ingreso-egreso/SKILL.md b/skills/fiscalapi-ingreso-egreso/SKILL.md
index d04331d..7950694 100644
--- a/skills/fiscalapi-ingreso-egreso/SKILL.md
+++ b/skills/fiscalapi-ingreso-egreso/SKILL.md
@@ -1,9 +1,9 @@
---
name: fiscalapi-ingreso-egreso
-description: Arma con FiscalAPI una factura de ingreso, una nota de crédito (egreso) o una factura global CFDI 4.0 sin rechazos del SAT. Decide régimen fiscal y uso CFDI del receptor, objeto de impuesto e impuestos del concepto, método y forma de pago (PUE o PPD), la relación de la nota de crédito y la información global de público en general, y busca las claves del SAT con la API de catálogos. Úsala cuando vayas a emitir o corregir una factura de ingreso, egreso o global. Para el complemento de pago usa fiscalapi-pagos; para un error que ya devolvió la API, fiscalapi-diagnostico.
+description: Timbra con FiscalAPI CFDI/Factura de ingreso, CFDI/Factura de egreso (nota de crédito) y CFDI/Factura global sin rechazos del SAT. Decide régimen fiscal y uso CFDI del receptor, objeto de impuesto e impuestos del concepto, método y forma de pago, la relación de la nota de crédito y la información global de público en general, y busca las claves del SAT con la API de catálogos. Úsala cuando vayas a emitir o corregir una factura de ingreso, egreso o global. No la uses para el complemento de pago (usa `fiscalapi-pagos`) ni para un error que ya devolvió la API (usa `fiscalapi-diagnostico`).
---
-# Ingreso, nota de crédito y factura global
+# CFDI/Factura de ingreso, CFDI/Factura de egreso (nota de crédito) y CFDI/Factura global
Las tres se emiten con `POST /api/v4/invoices`; cambian `typeCode`, el receptor, `relatedInvoices` y `globalInformation`. Antes de escribir el cuerpo, lee la sección del caso (por valores; cambia `invoices-by-value` por `invoices-by-reference` si usas referencias):
@@ -14,7 +14,7 @@ Las tres se emiten con `POST /api/v4/invoices`; cambian `typeCode`, el receptor,
| Factura global (público en general) | [Crear factura global](https://docs.fiscalapi.com/invoices-by-value/crear-factura-global.md) y [Aspectos clave de la factura global](https://docs.fiscalapi.com/global-invoice-info/aspectos-clave-de-la-factura-global.md) |
| Todos los campos | [Modelo factura](https://docs.fiscalapi.com/invoices-by-value/modelo-factura.md) |
-El ambiente, las llaves y el SDK están en la skill `fiscalapi-integracion`.
+El ambiente, la API key y el SDK están en la skill `fiscalapi-integracion`.
## 1. Claves del SAT: búscalas, no las inventes
@@ -33,14 +33,14 @@ Una clave que no existe responde 400 en su campo antes de llegar al PAC ([Claves
3. `recipient.cfdiUseCode` debe aplicar al tipo de persona y admitir el régimen del receptor ([usos de CFDI](https://docs.fiscalapi.com/catalogs-info/cfdi-40/usos-de-cfdi.md)). Si no, CFDI40161. G01 y G03 no admiten 616 ni 605; S01 y CP01 admiten cualquier régimen.
4. `recipient.zipCode` es el código postal del domicilio fiscal del receptor, 5 dígitos, el de su constancia.
-FiscalAPI valida 2 y 3 antes del PAC y responde 400 en `Recipient.TaxRegimeCode` o `Recipient.CfdiUseCode` ([Mensajes de validación del timbrado](https://docs.fiscalapi.com/errors/mensajes-de-validacion-del-timbrado.md)). Pide los datos fiscales al cliente; no los deduzcas.
+FiscalAPI valida 2 y 3 antes del PAC y responde 400 en `Recipient.TaxRegimeCode` o `Recipient.CfdiUseCode` ([Mensajes de validación del timbrado](https://docs.fiscalapi.com/errors/mensajes-de-validacion-del-timbrado.md)). Pide los datos fiscales al receptor; no los deduzcas.
## 3. Conceptos e impuestos
- `itemCode` es la clave de producto o servicio (8 caracteres) y `unitOfMeasurementCode` la de unidad; búscalas en el catálogo. Por valores, `itemSku` es obligatorio.
- `taxObjectCode` ([objeto de impuesto](https://docs.fiscalapi.com/catalogs-info/cfdi-40/objeto-imp.md)):
- - `02` (sí objeto de impuesto) exige al menos un impuesto en `itemTaxes`; sin él, FiscalAPI rechaza el concepto.
- - `01`, `03` y `04` no llevan impuestos: FiscalAPI ignora `itemTaxes` con esos valores.
+ - `02` (sí objeto de impuesto) exige al menos un impuesto en `itemTaxes`; sin él, la API responde 400 antes de llegar al PAC.
+ - `01`, `03` y `04` no desglosan impuestos: deja `itemTaxes` vacío.
- `07` exige IEPS (`003`) y no admite IVA.
- Cada impuesto: `taxCode` (`001` ISR, `002` IVA, `003` IEPS), `taxTypeCode` (`Tasa`, `Cuota` o `Exento`), `taxRate` y `taxFlagCode` (`T` traslado, `R` retención). Una retención no puede ser `Exento`.
- Decimales: `quantity`, `unitPrice`, `discount` y `taxRate` admiten hasta 6; FiscalAPI no redondea ([Decimales](https://docs.fiscalapi.com/errors/decimales-de-importes-cantidades-y-tasas.md)). En JavaScript, redondea antes de enviar: `0.1 + 0.2` llega como `0.30000000000000004` y se rechaza.
@@ -48,7 +48,7 @@ FiscalAPI valida 2 y 3 antes del PAC y responde 400 en `Recipient.TaxRegimeCode`
## 4. Método y forma de pago
- **PUE** (pago en una sola exhibición): ya se pagó o se paga al emitir. `paymentFormCode` es la forma real (`01` efectivo, `03` transferencia, `04` tarjeta de crédito, ...).
-- **PPD** (pago en parcialidades o diferido): todavía no se paga. `paymentFormCode` es `99` (por definir), y cada pago posterior se registra con un complemento de pago (skill `fiscalapi-pagos`). El PAC rechaza PPD con otra forma (CFDI40105) y FiscalAPI no lo valida antes: revísalo tú.
+- **PPD** (pago en parcialidades o diferido): todavía no se paga. `paymentFormCode` es `99` (por definir), y cada pago posterior se registra con un complemento de pago (skill `fiscalapi-pagos`). Con PPD y otra forma de pago, el PAC rechaza con CFDI40105.
- Catálogos: [formas de pago](https://docs.fiscalapi.com/catalogs-info/cfdi-40/formas-de-pago.md) y [métodos de pago](https://docs.fiscalapi.com/catalogs-info/cfdi-40/metodos-de-pago.md).
## 5. Nota de crédito (egreso)
@@ -57,7 +57,7 @@ FiscalAPI valida 2 y 3 antes del PAC y responde 400 en `Recipient.TaxRegimeCode`
- `01`: descuento o bonificación sobre la factura relacionada.
- `03`: devolución de mercancía.
- `07`: aplicación de anticipo (con forma de pago `30`).
-- Método `PUE`; la forma de pago es la del reembolso o descuento (`15` condonación si el ingreso aún no se pagó).
+- Método `PUE`; la forma de pago es con la que se efectúa la devolución, el descuento o la bonificación (`15` condonación si el ingreso aún no se pagó).
- Concepto típico: `itemCode` `84111506` (servicios de facturación), unidad `ACT`, cantidad 1.
- Primero emite y guarda el `uuid` del ingreso: la nota de crédito no existe sin él.
@@ -66,10 +66,18 @@ FiscalAPI valida 2 y 3 antes del PAC y responde 400 en `Recipient.TaxRegimeCode`
- Receptor: `tin` `XAXX010101000`, `legalName` `PUBLICO EN GENERAL`, `taxRegimeCode` `616`, `cfdiUseCode` `S01` y `zipCode` igual a `expeditionZipCode`. Con el RFC genérico, otro régimen da CFDI40159 y otro código postal CFDI40149.
- `globalInformation` es obligatoria en un ingreso a público en general (CFDI40130): `periodicityCode` (`01` diario, `02` semanal, `03` quincenal, `04` mensual, `05` bimestral), `monthCode` y `year` ([periodo de facturación](https://docs.fiscalapi.com/global-invoice-info/periodo-de-facturacion.md)).
- `year` es el de la fecha de la factura o hasta 5 años antes (CFDI40136).
- - `monthCode` va de `01` a `12`; solo con `05` (bimestral, emisor en régimen 621) va de `13` a `18`. FiscalAPI no valida esta combinación antes del PAC (CFDI40132, CFDI40134, CFDI40135).
+ - `monthCode` va de `01` a `12`; solo con `05` (bimestral, emisor en régimen 621) va de `13` a `18`. Si no se cumple, el PAC rechaza: CFDI40132 si hay periodicidad `05` y el emisor no está en 621, y CFDI40134 o CFDI40135 si el mes no corresponde a la periodicidad.
- Método `PUE`. Conceptos: `itemCode` `01010101`, unidad `ACT`, cantidad 1, un concepto por venta o ticket con su folio en `itemSku`.
-- Si las ventas son tickets de Auto CFDI, usa [Crear factura global de tickets](https://docs.fiscalapi.com/tickets/crear-factura-global-de-tickets.md).
+- Si las ventas están registradas como tickets en FiscalAPI, usa [Crear factura global de tickets](https://docs.fiscalapi.com/tickets/crear-factura-global-de-tickets.md).
-## 7. Después de emitir
+## 7. Antes de timbrar
-Guarda `id` y `uuid`. Para consultar el estatus ante el SAT, cancelar o descargar el PDF y el XML, usa la skill `fiscalapi-ciclo-de-vida`. En el ambiente de pruebas el estatus del SAT se simula.
+1. Cada clave del SAT existe en su catálogo; las confirmaste con la API de catálogos.
+2. El régimen fiscal, el uso CFDI y el código postal del receptor vienen de su constancia y corresponden a su tipo de persona.
+3. Cada concepto con `taxObjectCode` `02` lleva al menos un impuesto; con `01`, `03` o `04`, `itemTaxes` va vacío.
+4. Ninguna cantidad, precio, descuento o tasa trae más de 6 decimales.
+5. Con PPD, `paymentFormCode` es `99`; con PUE, es la forma real del pago.
+6. La nota de crédito relaciona el `uuid` de la factura de ingreso con un solo tipo de relación.
+7. La factura global lleva el receptor genérico, `globalInformation` y un `monthCode` que corresponde a la periodicidad (con `05`, el emisor está en régimen 621).
+
+Guarda el `id` y el `uuid` que devuelve la API. Si el PAC rechaza, busca el código en [Errores CFDI 4.0](https://docs.fiscalapi.com/errors/cfdi-40/errores-cfdi-4-0.md) o usa la skill `fiscalapi-diagnostico`. Para cancelar, consultar el estatus ante el SAT o descargar el XML y el PDF, usa la skill `fiscalapi-ciclo-de-vida`; ahí está también cómo se comporta el estatus en el ambiente de pruebas.
diff --git a/skills/fiscalapi-integracion/SKILL.md b/skills/fiscalapi-integracion/SKILL.md
index 23b0ec1..a11ae40 100644
--- a/skills/fiscalapi-integracion/SKILL.md
+++ b/skills/fiscalapi-integracion/SKILL.md
@@ -1,29 +1,29 @@
---
name: fiscalapi-integracion
-description: Integra FiscalAPI (facturación electrónica CFDI 4.0 en México) en un ERP, POS o SaaS. Elige el SDK según el lenguaje del proyecto, configura el ambiente de pruebas y las llaves (X-API-KEY, X-TENANT-KEY, X-TIME-ZONE), decide entre el modo por valores y por referencias, y lleva a la sección exacta de docs.fiscalapi.com y a la skill de cada tipo de factura. Úsala cuando te pidan integrar FiscalAPI, timbrar o emitir facturas CFDI desde código, o empezar con FiscalAPI desde cero. No la uses para diagnosticar un error que ya devolvió la API (usa fiscalapi-diagnostico).
+description: Integra FiscalAPI (facturación electrónica CFDI 4.0 en México) en un ERP, POS o SaaS. Elige el SDK según el lenguaje del proyecto, configura el ambiente de pruebas y los headers X-API-KEY, X-TENANT-KEY y X-TIME-ZONE, decide entre el modo por valores y por referencias, y lleva a la sección exacta de docs.fiscalapi.com y a la skill de cada tipo de factura. Úsala cuando te pidan integrar FiscalAPI, timbrar o emitir facturas CFDI desde código, o empezar con FiscalAPI desde cero. No la uses para diagnosticar un error que ya devolvió la API (usa `fiscalapi-diagnostico`).
---
# Integrar FiscalAPI
-FiscalAPI es una API REST para emitir CFDI 4.0 y sus complementos, cancelarlos y consultarlos ante el SAT. Esta skill decide cómo integrarla; el detalle de cada endpoint vive en las docs, en copias Markdown por sección (`https://docs.fiscalapi.com//.md`). Lee la sección que corresponda antes de escribir el cuerpo de una petición: no lo inventes.
+FiscalAPI es una API REST para emitir CFDI 4.0 y sus complementos, cancelarlos y consultarlos ante el SAT. Esta skill cubre el ambiente, la API key, el SDK, el modo de operación y la sección de docs y la skill que corresponden a cada tipo de factura; el detalle de cada operación vive en las docs, en copias Markdown por sección (`https://docs.fiscalapi.com//.md`). Lee la sección que corresponda antes de escribir el cuerpo de una petición: no lo inventes.
Para timbrar la primera factura de punta a punta en un proyecto vacío, usa el comando `primer-timbre`.
-## 1. Ambiente y llaves
+## 1. Ambiente y API key
-- Trabaja siempre en **pruebas**: `https://test.fiscalapi.com`. Producción (`https://live.fiscalapi.com`) es otra cuenta con otras llaves; pasar a producción cambia solo la URL, la API key y el tenant ([Ambientes](https://docs.fiscalapi.com/environments.md)).
+- Trabaja siempre en **pruebas**: `https://test.fiscalapi.com`. Producción (`https://live.fiscalapi.com`) es otra cuenta con otra API key y otro tenant; pasar a producción cambia solo la URL, la API key y el tenant ([Ambientes](https://docs.fiscalapi.com/environments.md)).
- Cada petición lleva `X-API-KEY` y `X-TENANT-KEY` ([Encabezados requeridos](https://docs.fiscalapi.com/authentication/encabezados-http-requeridos.md)) y `X-TIME-ZONE` con una zona IANA, por ejemplo `America/Mexico_City`: las fechas sin zona del JSON se leen y se escriben en esa zona, y si falta se usa `America/Mexico_City`. Los SDK mandan los tres headers.
-- Las llaves se leen de variables de entorno (`FISCALAPI_API_URL`, `FISCALAPI_API_KEY`, `FISCALAPI_TENANT`). Nunca las escribas en el código, en un archivo versionado ni en el chat, y nunca uses una llave de producción para probar.
+- La API key y el tenant se leen de variables de entorno (`FISCALAPI_API_URL`, `FISCALAPI_API_KEY`, `FISCALAPI_TENANT`). Nunca los escribas en el código, en un archivo versionado ni en el chat, y nunca uses una API key de producción para probar.
-Si el integrador todavía no tiene llaves de pruebas, guíalo por el flujo de [Pasos para comenzar](https://docs.fiscalapi.com/quickstart/pasos-para-comenzar.md): sin suscripción activa y sin timbres no se puede timbrar.
+Si el proyecto todavía no tiene una API key de pruebas, indica los pasos de [Pasos para comenzar](https://docs.fiscalapi.com/quickstart/pasos-para-comenzar.md): sin suscripción activa y sin timbres no se puede timbrar.
1. Crear la cuenta en `https://test.fiscalapi.com/create-account` y confirmar el correo.
-2. [Activar la suscripción de prueba](https://docs.fiscalapi.com/subscription-info/activar-suscripcion-de-prueba.md) y [comprar timbres de prueba](https://docs.fiscalapi.com/subscription-info/activar-timbres-de-prueba.md) con una [tarjeta de prueba](https://docs.fiscalapi.com/testing-data/tarjetas-de-prueba.md). Los pagos se hacen solo desde el dashboard.
+2. [Activar la suscripción de prueba](https://docs.fiscalapi.com/subscription-info/activar-suscripcion-de-prueba.md) y [comprar timbres de prueba](https://docs.fiscalapi.com/subscription-info/activar-timbres-de-prueba.md) con una [tarjeta de prueba](https://docs.fiscalapi.com/testing-data/tarjetas-de-prueba.md). El pago de la suscripción y de los timbres se hace desde el dashboard.
3. [Generar la API key](https://docs.fiscalapi.com/credentials-info/generar-una-api-key.md) y copiar el [tenant (TID)](https://docs.fiscalapi.com/credentials-info/obtener-el-tenant.md) del perfil.
## 2. SDK
-Usa el SDK oficial del lenguaje del proyecto; el paquete, la creación del cliente y las trampas de cada README están en [references/sdks.md](references/sdks.md). Si el lenguaje no tiene SDK, llama a la API por HTTP con los tres headers.
+Usa el SDK oficial del lenguaje del proyecto; el paquete, la creación del cliente y los detalles de uso de cada uno están en [references/sdks.md](references/sdks.md). Si el lenguaje no tiene SDK, llama a la API por HTTP con los tres headers.
| El proyecto tiene | SDK |
|---|---|
@@ -37,15 +37,15 @@ Usa el SDK oficial del lenguaje del proyecto; el paquete, la creación del clien
Los dos modos usan el mismo `POST /api/v4/invoices` y dan el mismo CFDI ([Diferencias entre modos](https://docs.fiscalapi.com/modes-of-operation/diferencias-entre-modos-de-operacion.md)).
-- **Por valores:** el cuerpo lleva todos los datos del emisor, del receptor y de los conceptos, incluidos los certificados CSD del emisor en base64 (`taxCredentials`). No hay que registrar nada antes. Conviene para la primera prueba y cuando el sistema del integrador ya guarda esos datos.
-- **Por referencias:** el cuerpo lleva los `id` de personas y productos ya registrados en FiscalAPI ([emisores](https://docs.fiscalapi.com/issuers.md), [receptores](https://docs.fiscalapi.com/recipients.md), [productos](https://docs.fiscalapi.com/products.md), [certificados](https://docs.fiscalapi.com/tax-files.md)). Peticiones más chicas y sin certificados en cada llamada; es el modo recomendado para producción.
+- **Por valores:** el cuerpo lleva todos los datos del emisor, del receptor y de los conceptos, incluidos los certificados CSD del emisor en base64 (`taxCredentials`). No hay que registrar nada antes. Conviene para la primera prueba y cuando tu sistema ya guarda esos datos.
+- **Por referencias:** el cuerpo lleva los `id` de personas y productos ya registrados en FiscalAPI ([emisores](https://docs.fiscalapi.com/issuers.md), [receptores](https://docs.fiscalapi.com/recipients.md), [productos](https://docs.fiscalapi.com/products.md), [certificados](https://docs.fiscalapi.com/tax-files.md)). Peticiones más pequeñas y sin certificados en cada llamada; es el modo recomendado para producción.
Las secciones de facturas existen en las dos variantes con la misma ancla: basta cambiar `invoices-by-value` por `invoices-by-reference` en la URL.
## 4. Datos de prueba
- El SAT exige usar sus [personas de prueba](https://docs.fiscalapi.com/testing-data/personas-de-prueba.md) en este ambiente (RFC, razón social y código postal); no inventes RFC.
-- Los CSD de prueba vienen de SW ([Certificados de prueba](https://docs.fiscalapi.com/testing-data/certificados-de-prueba.md)); todas las llaves usan la contraseña `12345678a`. Cada persona trae también su FIEL: para timbrar usa el `.cer` y el `.key` de la subcarpeta `CSD_*`, no el `.cer` ni el `Claveprivada_FIEL_*.key` de la carpeta de la persona.
+- Los CSD de prueba están en [Certificados de prueba](https://docs.fiscalapi.com/testing-data/certificados-de-prueba.md); todas las llaves privadas usan la contraseña `12345678a`. Cada persona trae también su FIEL: para timbrar usa el Certificado CSD (.cer) y la Llave privada CSD (.key) de la subcarpeta `CSD_*`, no el Certificado FIEL (.cer) (`.cer`) ni la Llave privada FIEL (.key) (`Claveprivada_FIEL_*.key`) de la carpeta de la persona.
- El ejemplo de [Crear factura ingreso por valores](https://docs.fiscalapi.com/invoices-by-value/crear-factura-ingreso.md) usa al emisor `FUNK671228PH6` (régimen 621) y al receptor `EKU9003173C9` (régimen 601, uso G01, CP 42501).
## 5. Qué vas a construir
@@ -60,16 +60,16 @@ Las secciones de facturas existen en las dos variantes con la misma ancla: basta
| Factura global | [crear-factura-global](https://docs.fiscalapi.com/invoices-by-value/crear-factura-global.md) | `fiscalapi-ingreso-egreso` |
| Cancelar, PDF, XML, correo o estatus ante el SAT | [cancelar-factura](https://docs.fiscalapi.com/invoices-by-value/cancelar-factura.md), [generar-pdf-por-id](https://docs.fiscalapi.com/invoices-by-value/generar-pdf-por-id.md), [obtener-xml-por-id](https://docs.fiscalapi.com/invoices-by-value/obtener-xml-por-id.md), [enviar-factura-por-correo](https://docs.fiscalapi.com/invoices-by-value/enviar-factura-por-correo.md), [consultar-estado-de-factura](https://docs.fiscalapi.com/invoices-by-value/consultar-estado-de-factura.md) | `fiscalapi-ciclo-de-vida` |
| Un error que ya devolvió la API | [Errores](https://docs.fiscalapi.com/errors.md) | `fiscalapi-diagnostico` |
-| Catálogos del SAT | [Catálogos](https://docs.fiscalapi.com/catalogs.md) | — |
+| Catálogos del SAT | [Catálogos](https://docs.fiscalapi.com/catalogs.md) | Ninguna |
| Notificaciones de eventos (webhooks) | [Webhooks](https://docs.fiscalapi.com/webhooks-info.md) | `fiscalapi-ciclo-de-vida` |
-| Descarga masiva de CFDI del SAT | [Descarga masiva](https://docs.fiscalapi.com/download-info.md) | — |
-| Validaciones ante el SAT (69-B, sellos, estatus) | [Validaciones SAT](https://docs.fiscalapi.com/sat-validations-info.md) | — |
-| Autofactura de tickets | [Auto CFDI](https://docs.fiscalapi.com/auto-invoice.md) | — |
+| Descarga masiva de CFDI del SAT | [Descarga masiva](https://docs.fiscalapi.com/download-info.md) | Ninguna |
+| Validaciones ante el SAT (69-B, sellos, estatus) | [Validaciones SAT](https://docs.fiscalapi.com/sat-validations-info.md) | Ninguna |
+| Autofactura de tickets | [Auto CFDI](https://docs.fiscalapi.com/auto-invoice.md) | Ninguna |
-## 6. Reglas que no se negocian
+## 6. Reglas clave
-- **Un 5xx o un timeout al timbrar no se reintenta.** El CFDI pudo quedar timbrado y un reintento lo duplicaría. `POST /api/v4/invoices` no es idempotente. Registra la respuesta completa (incluido `traceIdentifier`), marca la factura como "resultado incierto" en el sistema del integrador y sigue [Errores del servidor](https://docs.fiscalapi.com/errors/errores-del-servidor.md): búscala con [Listar facturas](https://docs.fiscalapi.com/invoices-by-value/listar-facturas.md) y, si no aparece, escribe a soporte con el `traceIdentifier` antes de volver a emitir.
-- **Un 400 con fallas de validación o un rechazo del PAC no consumen timbre**: corrige el dato y vuelve a emitir. Para interpretarlos usa `fiscalapi-diagnostico`.
+- **Un 5xx o un timeout al timbrar no se reintenta.** El CFDI pudo quedar timbrado y un reintento lo duplicaría. `POST /api/v4/invoices` no es idempotente. Registra la respuesta completa (incluido `traceIdentifier`), marca la factura como "resultado incierto" en tu sistema y sigue [Errores del servidor](https://docs.fiscalapi.com/errors/errores-del-servidor.md): búscala con [Listar facturas](https://docs.fiscalapi.com/invoices-by-value/listar-facturas.md) y, si no aparece, escribe a soporte con el `traceIdentifier` antes de volver a emitir.
+- **Un 400 con fallas de validación o un rechazo del PAC no consumen timbre**: corrige el dato y vuelve a emitir. Para interpretarlos usa la skill `fiscalapi-diagnostico`.
- **Lee `succeeded`, no solo el código HTTP.** Toda respuesta es un [ApiResponse](https://docs.fiscalapi.com/api-response.md); el CFDI emitido trae su `id` en FiscalAPI y su `uuid` (folio fiscal). Guarda los dos.
- **Las fechas van sin zona** (`yyyy-MM-ddTHH:mm:ss`), en la zona de `X-TIME-ZONE`.
-- **Nada en producción** desde un agente: timbrar en `live` es un acto fiscal irreversible.
+- **Nada en producción** desde un agente: timbrar en producción es un acto fiscal irreversible.
diff --git a/skills/fiscalapi-integracion/references/sdks.md b/skills/fiscalapi-integracion/references/sdks.md
index 0974b2d..d473758 100644
--- a/skills/fiscalapi-integracion/references/sdks.md
+++ b/skills/fiscalapi-integracion/references/sdks.md
@@ -1,48 +1,44 @@
# SDK oficiales de FiscalAPI
-Los cinco SDK tienen la misma forma: un objeto de configuración con la URL base, la API key, el tenant y la zona horaria; un cliente; un servicio de facturas con `create`; y una respuesta con `succeeded` y `data`. La URL base es libre (`https://test.fiscalapi.com` o la que indique `FISCALAPI_API_URL`) y el SDK agrega `/api/v4/...`. El repo de cada uno tiene su README con más ejemplos.
-
-Revisado contra la versión 4.0.450 de los cinco paquetes.
+Los cinco SDK tienen la misma forma: un objeto de configuración con la URL base, la API key, el tenant y la zona horaria; un cliente; un servicio de facturas con `create`; y una respuesta con `succeeded` y `data`. La URL base es libre (`https://test.fiscalapi.com` o la que indique `FISCALAPI_API_URL`) y el SDK agrega `/api/v4/...`. El repo de cada uno, enlazado en su título, tiene en su README los requisitos de runtime y más ejemplos.
## .NET: [fiscalapi-net](https://github.com/FiscalAPI/fiscalapi-net)
-- Instalar: `dotnet add package Fiscalapi` (netstandard2.0 y .NET Framework 4.6.1 en adelante).
-- Namespaces: `Fiscalapi.Common` (configuración y respuesta), `Fiscalapi.Services` (cliente) y `Fiscalapi.Models` (factura).
-- Cliente: `var client = FiscalApiClient.Create(new FiscalapiSettings { ApiUrl = ..., ApiKey = ..., Tenant = ..., TimeZone = "America/Mexico_City" });`. Con inyección de dependencias: `builder.Services.AddFiscalApi()` lee la sección `FiscalapiSettings` y requiere `using FiscalApi;` (con A mayúscula, a diferencia del resto del SDK).
-- Emitir: `var response = await client.Invoices.CreateAsync(invoice);` con `Invoice`, `InvoiceIssuer` (`TaxCredentials` con `FileType.CertificateCsd` y `FileType.PrivateKeyCsd`), `InvoiceRecipient`, `InvoiceItem` e `InvoiceItemTax`.
+- Instalar: `dotnet add package Fiscalapi`.
+- Cliente: `var client = FiscalApiClient.Create(new FiscalapiSettings { ApiUrl = ..., ApiKey = ..., Tenant = ..., TimeZone = "America/Mexico_City" });`, con `Fiscalapi.Common` (configuración y respuesta) y `Fiscalapi.Services` (cliente). Con inyección de dependencias: `builder.Services.AddFiscalApi()` lee la sección `FiscalapiSettings` y requiere `using FiscalApi;`.
+- Timbrar: `var response = await client.Invoices.CreateAsync(invoice);` con `Invoice`, `InvoiceIssuer` (`TaxCredentials` con `FileType.CertificateCsd` y `FileType.PrivateKeyCsd`), `InvoiceRecipient`, `InvoiceItem` e `InvoiceItemTax`, del namespace `Fiscalapi.Models`.
- Respuesta: `response.Succeeded`, `response.Data.Id`, `response.Data.Uuid`, `response.Message`, `response.Details`, `response.TraceIdentifier`.
-- Timeout fijo de 30 s.
+- Notas: el timeout es fijo de 30 s.
## Node: [fiscalapi-node](https://github.com/FiscalAPI/fiscalapi-node)
- Instalar: `npm install fiscalapi`.
-- Cliente: `import { FiscalapiClient } from 'fiscalapi'` y `FiscalapiClient.create({ apiUrl, apiKey, tenant, timeZone: 'America/Mexico_City' })`. **La clase es `FiscalapiClient`** (con `a` minúscula); los ejemplos del README escriben `FiscalApiClient`, que no existe.
-- Emitir: `const response = await client.invoices.create(invoice)` con un objeto plano; `taxCredentials` lleva `fileType: 0` (`.cer`) y `fileType: 1` (`.key`).
+- Cliente: `import { FiscalapiClient } from 'fiscalapi'` y `FiscalapiClient.create({ apiUrl, apiKey, tenant, timeZone: 'America/Mexico_City' })`. La clase es `FiscalapiClient`, con `a` minúscula.
+- Timbrar: `const response = await client.invoices.create(invoice)` con un objeto plano; `taxCredentials` lleva `fileType: 0` para el Certificado CSD (.cer) y `fileType: 1` para la Llave privada CSD (.key).
- Respuesta: `response.succeeded`, `response.data.id`, `response.data.uuid`, `response.message`, `response.details`.
- Fechas: manda `date` como texto `yyyy-MM-ddTHH:mm:ss` (por ejemplo con `DateTime.now().toFormat("yyyy-MM-dd'T'HH:mm:ss")` de luxon). Un `new Date()` viaja en UTC con `Z` y deja de ser la hora local de `X-TIME-ZONE`.
-- `debug: true` imprime los logs y además desactiva la validación SSL: no lo uses fuera de tu máquina.
+- Notas: usa `debug: true` solo en desarrollo local.
## Python: [fiscalapi-python](https://github.com/FiscalAPI/fiscalapi-python)
-- Instalar: `pip install fiscalapi` (Python 3.9 o más reciente).
+- Instalar: `pip install fiscalapi`.
- Cliente: `from fiscalapi import FiscalApiClient, FiscalApiSettings` y `client = FiscalApiClient(settings=FiscalApiSettings(api_url=..., api_key=..., tenant=..., time_zone="America/Mexico_City"))`.
-- Emitir: `response = client.invoices.create(invoice)` con `Invoice`, `InvoiceIssuer` (`tax_credentials=[TaxCredential(base64_file=..., file_type=0, password=...), TaxCredential(..., file_type=1, ...)]`), `InvoiceRecipient`, `InvoiceItem` e `ItemTax`.
+- Timbrar: `response = client.invoices.create(invoice)` con `Invoice`, `InvoiceIssuer` (`tax_credentials=[TaxCredential(base64_file=..., file_type=0, password=...), TaxCredential(..., file_type=1, ...)]`), `InvoiceRecipient`, `InvoiceItem` e `ItemTax`.
- Respuesta: `response.succeeded`, `response.data.id`, `response.data.uuid`, `response.message`, `response.details`, `response.trace_identifier`.
- Fechas: `date=datetime.now().strftime("%Y-%m-%dT%H:%M:%S")`; un `datetime` sin formatear manda microsegundos.
-- El SDK no fija timeout en `requests`.
+- Notas: las llamadas del cliente no tienen timeout. Si tu sistema necesita un límite de espera, aplícalo desde tu código y trata su vencimiento al timbrar como un timeout: resultado incierto, sin reintento.
## PHP: [fiscalapi-php](https://github.com/FiscalAPI/fiscalapi-php)
-- Instalar: `composer require fiscalapi/fiscalapi` (PHP 7.4 o más reciente).
+- Instalar: `composer require fiscalapi/fiscalapi`.
- Cliente: `$settings = new \Fiscalapi\Http\FiscalApiSettings($apiUrl, $apiKey, $tenant);` y `$client = new \Fiscalapi\Services\FiscalApiClient($settings);`. Los argumentos son posicionales: después vienen `debug`, `verifySsl`, `apiVersion` y `timeZone`.
-- Emitir: `$response = $client->getInvoiceService()->create($invoice);` con un arreglo asociativo con las mismas claves del JSON de las docs.
-- Respuesta: `$json = $response->getJson();` y luego `$json['succeeded']`, `$json['data']['id']`, `$json['data']['uuid']`. `isSuccessful()` solo revisa que el HTTP sea 2xx: usa `succeeded`.
+- Timbrar: `$response = $client->getInvoiceService()->create($invoice);` con un arreglo asociativo con las mismas claves del JSON de las docs.
+- Respuesta: `$json = $response->getJson();` y luego `$json['succeeded']`, `$json['data']['id']`, `$json['data']['uuid']`. Decide si la operación tuvo éxito con `$json['succeeded']`, no con `isSuccessful()`.
## Java: [fiscalapi-java](https://github.com/FiscalAPI/fiscalapi-java)
-- Instalar: `com.fiscalapi:fiscalapi:4.0.450` en Maven o Gradle (Java 8 o más reciente).
-- Paquetes: `com.fiscalapi.common` (configuración y respuesta), `com.fiscalapi.services` (cliente) y `com.fiscalapi.models.invoicing` (factura).
-- Cliente: `FiscalApiSettings` con `setApiUrl`, `setApiKey`, `setTenant` y `setTimeZone`; `FiscalApiClient client = FiscalApiClient.create(settings);`.
-- Emitir: `ApiResponse response = client.getInvoiceService().create(invoice);`. `TaxCredential` usa `setFileType(0)` y `setFileType(1)`.
-- Los importes, cantidades y tasas son `BigDecimal` (`new BigDecimal("0.160000")`); los ejemplos del README que pasan `double` no compilan.
+- Instalar: `com.fiscalapi:fiscalapi` en Maven o Gradle, con la versión más reciente publicada.
+- Cliente: `FiscalApiSettings` con `setApiUrl`, `setApiKey`, `setTenant` y `setTimeZone`; `FiscalApiClient client = FiscalApiClient.create(settings);`, con `com.fiscalapi.common` (configuración y respuesta) y `com.fiscalapi.services` (cliente).
+- Timbrar: `ApiResponse response = client.getInvoiceService().create(invoice);`, con los modelos de `com.fiscalapi.models.invoicing`. `TaxCredential` usa `setFileType(0)` y `setFileType(1)`.
- Respuesta: `response.isSucceeded()`, `response.getData().getId()`, `response.getData().getUuid()`.
+- Tipos: los importes, cantidades y tasas son `BigDecimal` (`new BigDecimal("0.160000")`), no `double`.
diff --git a/skills/fiscalapi-nomina/SKILL.md b/skills/fiscalapi-nomina/SKILL.md
index 3c3b5c6..ea2b622 100644
--- a/skills/fiscalapi-nomina/SKILL.md
+++ b/skills/fiscalapi-nomina/SKILL.md
@@ -1,20 +1,22 @@
---
name: fiscalapi-nomina
-description: Emite con FiscalAPI un recibo de nómina CFDI 4.0 con complemento de nómina 1.2 (factura tipo N), ordinaria o extraordinaria. Decide los datos del empleado y del empleador (por referencias en la persona o por valores en el request), el tipo de nómina, el periodo y los días pagados, las percepciones, las deducciones (ISR, IMSS), los otros pagos con el subsidio para el empleo y las incapacidades, y evita los rechazos NOM más comunes. Úsala cuando vayas a timbrar nómina o recibos de pago de sueldos, o a cargar los datos laborales de empleados y empleadores. Para un error NOM que ya devolvió la API, usa fiscalapi-diagnostico.
+description: Timbra con FiscalAPI un CFDI/Factura de nómina (tipo N, complemento de nómina 1.2), ordinaria o extraordinaria, con los datos del empleado y del empleador por referencias o por valores, el periodo, las percepciones, las deducciones, los otros pagos con el subsidio para el empleo y las incapacidades, sin los rechazos NOM más comunes. Úsala cuando vayas a timbrar recibos de nómina o a registrar los datos laborales de empleados y empleadores. No la uses para diagnosticar un error NOM que ya devolvió la API (usa `fiscalapi-diagnostico`).
---
-# Recibo de nómina 1.2
+# CFDI/Factura de nómina (complemento de nómina 1.2)
-Antes de escribir el cuerpo, lee [Crear factura de nómina](https://docs.fiscalapi.com/invoices-by-value/crear-factura-de-nomina.md) (por referencias: [esta sección](https://docs.fiscalapi.com/invoices-by-reference/crear-factura-de-nomina.md)). El ambiente, las llaves y el SDK están en la skill `fiscalapi-integracion`. Ojo con los ejemplos de C# y Python de esa página: usan un receptor 601/G01 y `PaymentFormCode`, que la nómina no admite; sigue esta skill.
+El CFDI/Factura de nómina (tipo `N`) ampara el pago de sueldos y salarios de un empleador a un empleado con el complemento de nómina 1.2. Es ordinaria cuando corresponde a un periodo de pago habitual y extraordinaria cuando el pago queda fuera de esa periodicidad.
+
+Antes de escribir el cuerpo, lee [Crear factura de nómina](https://docs.fiscalapi.com/invoices-by-value/crear-factura-de-nomina.md) (por referencias: [esta sección](https://docs.fiscalapi.com/invoices-by-reference/crear-factura-de-nomina.md)). El ambiente, la API key y el SDK están en la skill `fiscalapi-integracion`.
## 1. El comprobante
`POST /api/v4/invoices` con:
- `typeCode` `N`, `currencyCode` `MXN`, `exportCode` `01`, `paymentMethodCode` `PUE`, `series`, `date` y `expeditionZipCode`.
-- **No mandes** `paymentFormCode` (el CFDI de nómina no lleva forma de pago y FiscalAPI lo copiaría al XML), `items`, `paymentConditions` ni `exchangeRate`. FiscalAPI arma solo el concepto (84111505, ACT, "Pago de nómina") con importe = percepciones + otros pagos y descuento = deducciones.
+- **No mandes** `paymentFormCode` (el CFDI de nómina no lleva forma de pago), `items`, `paymentConditions` ni `exchangeRate`. La API arma el concepto (84111505, ACT, "Pago de nómina") con importe = percepciones + otros pagos y descuento = deducciones.
- **Emisor** (empleador): si es persona física, necesita su CURP (NOM5); una persona moral no la lleva.
-- **Receptor** (empleado): RFC de persona física (13 caracteres, NOM8), `taxRegimeCode` `605` (sueldos y salarios, NOM11) y `cfdiUseCode` `CN01` (nómina, NOM12). FiscalAPI no los fuerza: mándalos.
+- **Receptor** (empleado): RFC de persona física (13 caracteres, NOM8), `taxRegimeCode` `605` (sueldos y salarios, NOM11) y `cfdiUseCode` `CN01` (nómina, NOM12). Mándalos siempre en el cuerpo: con otros valores, el PAC rechaza con esos códigos.
## 2. Datos laborales: por referencias o por valores
@@ -26,7 +28,7 @@ Datos del empleado que más fallan:
- Obligatorios: `curp`, `employeeNumber` (1-15 caracteres), `satContractTypeId`, `satTaxRegimeTypeId`, `satPaymentPeriodicityId` y `satPayrollStateId` (estado donde trabaja, por ejemplo `JAL`).
- Contrato `01` a `08` (con relación laboral): el emisor necesita `employerRegistration` (registro patronal) y el empleado `socialSecurityNumber`, `laborRelationStartDate`, `seniority`, `satJobRiskId` e `integratedDailySalary` (NOM42, NOM44); el régimen debe ser `02`, `03` o `04` (NOM57). Contratos `09`, `10` y `99` no llevan registro patronal (NOM43).
- `seniority` es la antigüedad al final del periodo: `P{semanas}W` (por ejemplo `P54W`, sin pasarte de las semanas cumplidas, NOM54) o `P{a}Y{m}M{d}D` exacta (NOM55).
-- Los catálogos de nómina distinguen acentos y mayúsculas: sindicalizado es `Sí` o `No`, no `Si` ni `SÍ` ([catálogos de nómina](https://docs.fiscalapi.com/catalogs-info/nomina-12/sindicalizado.md)).
+- Los valores de los [catálogos de nómina](https://docs.fiscalapi.com/catalogs/catalogos-de-nomina.md) distinguen acentos y mayúsculas: [sindicalizado](https://docs.fiscalapi.com/catalogs-info/nomina-12/sindicalizado.md) es `Sí` o `No`, no `Si` ni `SÍ`.
## 3. `complement.payroll`
@@ -40,7 +42,7 @@ Datos del empleado que más fallan:
| `deductions[]` | Deducciones: `deductionTypeCode` ([tipos](https://docs.fiscalapi.com/catalogs-info/nomina-12/tipo-deduccion.md); `001` seguridad social, `002` ISR), `code`, `concept`, `amount` |
| `disabilities[]` | Incapacidades: `disabilityDays`, `disabilityTypeCode`, `monetaryAmount` |
-`otherPayments` va **dentro** de `earnings`; `deductions` y `disabilities` van en la raíz de `payroll`. FiscalAPI calcula todos los totales (percepciones, gravado, exento, deducciones, impuestos retenidos y otros pagos): no los mandes. FiscalAPI no calcula el ISR: el monto de la deducción `002` lo calcula el sistema de nómina del integrador.
+`otherPayments` va **dentro** de `earnings`; `deductions` y `disabilities` van en la raíz de `payroll`. La API calcula todos los totales (percepciones, gravado, exento, deducciones, impuestos retenidos y otros pagos): no los mandes. El monto de la deducción `002` (ISR) lo calcula tu sistema de nómina y lo mandas ya calculado.
## 4. Subsidio para el empleo
@@ -55,4 +57,4 @@ Datos del empleado que más fallan:
3. Antigüedad que no exceda el periodo trabajado.
4. Subsidio `002` presente con régimen `02`.
-Si el PAC rechaza, busca el código en [Errores Nómina 1.2](https://docs.fiscalapi.com/errors/nomina-12/errores-nomina-1-2.md) o usa la skill `fiscalapi-diagnostico`. Para consultar el estatus, cancelar o enviar el recibo, usa `fiscalapi-ciclo-de-vida`.
+Si el PAC rechaza, busca el código en [Errores Nómina 1.2](https://docs.fiscalapi.com/errors/nomina-12/errores-nomina-1-2.md) o usa la skill `fiscalapi-diagnostico`. Para cancelar, consultar el estatus ante el SAT o descargar el XML y el PDF, usa la skill `fiscalapi-ciclo-de-vida`.
diff --git a/skills/fiscalapi-pagos/SKILL.md b/skills/fiscalapi-pagos/SKILL.md
index c6cbce4..e60dc15 100644
--- a/skills/fiscalapi-pagos/SKILL.md
+++ b/skills/fiscalapi-pagos/SKILL.md
@@ -1,24 +1,24 @@
---
name: fiscalapi-pagos
-description: Emite con FiscalAPI el complemento de recepción de pagos 2.0 (REP, factura de pago tipo P) de una factura PPD, en una o varias parcialidades. Decide cuándo hace falta (PUE frente a PPD), arma complement.payment con saldo anterior, importe pagado y saldo insoluto, el número de parcialidad, el tipo de cambio y la equivalencia cuando las monedas difieren, y los impuestos del documento pagado. Úsala cuando un cliente pague una factura emitida con método PPD o cuando te pidan un complemento de pago o un REP. Para la factura original usa fiscalapi-ingreso-egreso; para un error CRP que ya devolvió la API, fiscalapi-diagnostico.
+description: Timbra con FiscalAPI el CFDI/Factura de pago (complemento de recepción de pagos 2.0, REP, tipo P) de una factura PPD, en una o varias parcialidades. Decide si hace falta (PUE o PPD), arma `complement.payment` con saldo anterior, importe pagado y saldo insoluto, el número de parcialidad, el tipo de cambio y la equivalencia, y los impuestos del documento pagado. Úsala cuando el receptor pague una factura con método PPD o te pidan un REP. No la uses para la factura original (usa `fiscalapi-ingreso-egreso`) ni para un error CRP que ya devolvió la API (usa `fiscalapi-diagnostico`).
---
-# Complemento de pago (Pagos 2.0)
+# CFDI/Factura de pago (complemento de recepción de pagos 2.0)
-Una factura **PUE** ya está pagada: no lleva complemento. Una factura **PPD** (forma de pago `99`) se paga después, y cada pago se registra con un CFDI de tipo `P` que relaciona el UUID de la factura. El SAT pide emitirlo a más tardar el día 5 del mes siguiente al pago.
+Una factura con método de pago **PUE** ya está pagada: no lleva complemento. Una factura con método de pago **PPD** (y forma de pago `99`, por definir) se paga después, y cada pago se registra con un CFDI de tipo `P` que relaciona el UUID de la factura. El SAT pide emitirlo a más tardar el día 5 del mes siguiente al pago.
-Antes de escribir el cuerpo, lee [Crear factura de pago](https://docs.fiscalapi.com/invoices-by-value/crear-factura-de-pago.md) (por referencias: [esta sección](https://docs.fiscalapi.com/invoices-by-reference/crear-factura-de-pago.md)). Los campos de `complement.payment` están en [Modelo factura](https://docs.fiscalapi.com/invoices-by-value/modelo-factura.md). El ambiente y el SDK, en la skill `fiscalapi-integracion`.
+Antes de escribir el cuerpo, lee [Crear factura de pago](https://docs.fiscalapi.com/invoices-by-value/crear-factura-de-pago.md) (por referencias: [esta sección](https://docs.fiscalapi.com/invoices-by-reference/crear-factura-de-pago.md)). Los campos de `complement.payment` están en [Modelo factura](https://docs.fiscalapi.com/invoices-by-value/modelo-factura.md). El ambiente, la API key y el SDK están en la skill `fiscalapi-integracion`.
-## 1. El comprobante de pago
+## 1. Comprobante de pago
Se emite con `POST /api/v4/invoices`. Manda:
- `typeCode` `P`, `currencyCode` `XXX` (cualquier otra responde 400), `exchangeRate` 1 u omitido, `exportCode` `01`, `series`, `date`, `expeditionZipCode`, `issuer` y `recipient` (el mismo receptor de la factura).
- `complement.payment` con el pago (sección 2).
-**No mandes** `paymentFormCode`, `paymentMethodCode` ni `paymentConditions` en la raíz: el CFDI de pago no los lleva y el PAC lo rechaza (CRP20105, CRP20106, CRP20107). La forma de pago va dentro de `complement.payment`. Tampoco mandes `items`: FiscalAPI arma solo el concepto (84111506, ACT, "Pago", importe 0), fija el uso CFDI en `CP01` y calcula los totales del pago.
+**No mandes** `paymentFormCode`, `paymentMethodCode` ni `paymentConditions` en la raíz: el CFDI de pago no los lleva y el PAC lo rechaza (CRP20105, CRP20106, CRP20107). La forma de pago va dentro de `complement.payment`. Tampoco mandes `items`: la API arma el concepto (`84111506`, `ACT`, "Pago", importe 0), fija el uso CFDI en `CP01` y calcula los totales del pago.
-## 2. El pago y los documentos que paga
+## 2. Pago y documentos pagados
`complement.payment`:
@@ -36,11 +36,11 @@ Cada `paidInvoices[]`:
- `currencyCode` de la factura y `equivalence`: 1 si es la misma moneda del pago; si no, las unidades de la moneda de la factura por cada unidad de la del pago.
- `partialityNumber`: 1 en el primer pago de esa factura y consecutivo en los siguientes.
- `previousBalance`: en la primera parcialidad, el total de la factura; después, el saldo insoluto del pago anterior. Mayor que 0.
-- `paymentAmount`: lo que este pago abona a esa factura. Mayor que 0. (La tabla de las docs lo llama `amount`; el campo real es `paymentAmount`.)
-- `remainingBalance` = `previousBalance` − `paymentAmount`, nunca negativo (CRP20244).
+- `paymentAmount`: lo que este pago abona a esa factura. Mayor que 0.
+- `remainingBalance` = `previousBalance` menos `paymentAmount`, nunca negativo (CRP20244).
- `taxObjectCode` y `paidInvoiceTaxes`: ver la sección 3.
-**FiscalAPI no busca la factura ni calcula saldos.** Copia `uuid`, `partialityNumber` y los saldos tal como llegan, y el PAC rechaza lo incongruente. El sistema del integrador lleva el saldo y el número de parcialidad de cada factura PPD: guárdalos al emitir cada pago. Con la misma moneda, la suma de los `paymentAmount` no puede pasar de `amount` (CRP20217).
+**Tu sistema lleva el saldo de cada factura PPD.** La API toma `uuid`, `partialityNumber` y los saldos tal como llegan, y el PAC rechaza lo incongruente. Guarda el saldo y el número de parcialidad de cada factura PPD al emitir cada pago. Con la misma moneda, la suma de los `paymentAmount` no puede pasar de `amount` (CRP20217).
## 3. Impuestos del documento pagado
@@ -48,11 +48,11 @@ Cada `paidInvoices[]`:
- Cada impuesto lleva `base`, `taxCode`, `taxTypeCode`, `taxRate` y `taxFlagCode` como en la factura. La `base` es **proporcional a lo pagado**: si se paga la mitad de una factura de 1,000 + IVA, la base del IVA es 500. FiscalAPI calcula el importe de cada impuesto y los totales del pago a partir de esa base.
- Decimales: hasta 6 en importes y tasas, 10 en `equivalence`; FiscalAPI no redondea.
-## 4. Antes de timbrar, revisa
+## 4. Antes de timbrar
1. La factura es PPD y tienes su `uuid`, su total y los pagos previos.
2. `partialityNumber` y `previousBalance` siguen al pago anterior; `remainingBalance` cuadra.
3. La forma del pago no es `99` y, si mandas cuentas o RFC bancarios, la forma los admite (CRP20221 a CRP20228).
4. No hay `paymentFormCode` ni `paymentMethodCode` en la raíz.
-Si el PAC rechaza, busca el código en [Errores Pagos 2.0](https://docs.fiscalapi.com/errors/pagos-20/errores-pagos-2-0.md) o usa la skill `fiscalapi-diagnostico`. Para consultar el estatus del pago ante el SAT o cancelarlo, usa la skill `fiscalapi-ciclo-de-vida`.
+Si el PAC rechaza, busca el código en [Errores Pagos 2.0](https://docs.fiscalapi.com/errors/pagos-20/errores-pagos-2-0.md) o usa la skill `fiscalapi-diagnostico`. Para cancelar, consultar el estatus ante el SAT o descargar el XML y el PDF, usa la skill `fiscalapi-ciclo-de-vida`.