From f02ccc86c41e5234ab1e7ace46591031ddd09420 Mon Sep 17 00:00:00 2001 From: mendozagit Date: Thu, 22 Oct 2026 17:18:39 -0600 Subject: [PATCH 1/4] Eliminar el tablero de desarrollo de docs/ai-integracion --- docs/ai-integracion/README.md | 66 ---------- docs/ai-integracion/backend.md | 115 ---------------- docs/ai-integracion/decisiones.md | 140 -------------------- docs/ai-integracion/docs.md | 77 ----------- docs/ai-integracion/evals.md | 61 --------- docs/ai-integracion/mcp.md | 84 ------------ docs/ai-integracion/medicion.md | 66 ---------- docs/ai-integracion/requisitos.md | 170 ------------------------ docs/ai-integracion/skills.md | 211 ------------------------------ 9 files changed, 990 deletions(-) delete mode 100644 docs/ai-integracion/README.md delete mode 100644 docs/ai-integracion/backend.md delete mode 100644 docs/ai-integracion/decisiones.md delete mode 100644 docs/ai-integracion/docs.md delete mode 100644 docs/ai-integracion/evals.md delete mode 100644 docs/ai-integracion/mcp.md delete mode 100644 docs/ai-integracion/medicion.md delete mode 100644 docs/ai-integracion/requisitos.md delete mode 100644 docs/ai-integracion/skills.md 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. From cb1193c1a36a58fca0e9cc6f32a218c15744c38e Mon Sep 17 00:00:00 2001 From: mendozagit Date: Thu, 22 Oct 2026 17:58:40 -0600 Subject: [PATCH 2/4] docs-mcp: renombrar el servidor MCP a @fiscalapi/docs-mcp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El paquete solo consulta la especificación pública de la API y la documentación, así que el nombre y las herramientas ahora lo dicen: fiscalapi_list_operations, fiscalapi_get_operation y fiscalapi_search_docs, con parámetros en inglés y descripciones en español. - Código en inglés y sin comentarios. Cada herramienta es una clase que implementa McpTool y recibe sus dependencias por interfaces. - Scripts de build en TypeScript estricto: sin any ni aserciones de tipo, y con validación con zod al cargar los datos. - build-data: reintentos con backoff y jitter ante 429, 5xx y errores de red, cancelación al primer fallo y reemplazo atómico de data/. Se niega a borrar un directorio que no sea de datos. - CI: typecheck, build, pruebas, generación desde las fuentes públicas y contenido del paquete. prepack regenera dist y data. - Verificado: 178 pruebas en verde; build:data contra producción da 100 operaciones y 388 secciones; el servidor responde por stdio con el cliente MCP oficial. --- .github/workflows/check.yml | 50 ++- docs-mcp/.gitignore | 3 + {mcp => docs-mcp}/LICENSE | 0 docs-mcp/README.md | 93 +++++ {mcp => docs-mcp}/package-lock.json | 6 +- {mcp => docs-mcp}/package.json | 22 +- docs-mcp/scripts/build-data.ts | 152 +++++++ docs-mcp/scripts/docs-sections.ts | 153 +++++++ docs-mcp/scripts/http.ts | 62 +++ docs-mcp/scripts/openapi-operations.ts | 304 ++++++++++++++ docs-mcp/scripts/script-runner.ts | 34 ++ docs-mcp/scripts/snapshot-docs.ts | 23 + docs-mcp/src/api-operations.ts | 76 ++++ docs-mcp/src/data-files.ts | 24 ++ .../src/docs-index-options.ts | 18 +- docs-mcp/src/docs-index.ts | 56 +++ docs-mcp/src/excerpt.ts | 70 ++++ docs-mcp/src/index.ts | 30 ++ docs-mcp/src/operation-suggestions.ts | 38 ++ docs-mcp/src/server.ts | 29 ++ docs-mcp/src/tools/get-operation-tool.ts | 48 +++ docs-mcp/src/tools/list-operations-tool.ts | 54 +++ docs-mcp/src/tools/mcp-tool.ts | 5 + docs-mcp/src/tools/search-docs-tool.ts | 74 ++++ docs-mcp/src/tools/tool-conventions.ts | 19 + docs-mcp/src/tools/tool-results.ts | 9 + docs-mcp/test/build-data.test.ts | 394 ++++++++++++++++++ docs-mcp/test/docs-server.ts | 56 +++ docs-mcp/test/excerpt.test.ts | 50 +++ .../test/fixtures/docs-corpus.json.gz | Bin .../test/fixtures/openapi-v4.json | 0 docs-mcp/test/global-setup.ts | 30 ++ docs-mcp/test/helpers.ts | 53 +++ docs-mcp/test/http.test.ts | 85 ++++ docs-mcp/test/offline.test.ts | 30 ++ docs-mcp/test/run-build-data.ts | 22 + docs-mcp/test/search-docs-tool.test.ts | 107 +++++ docs-mcp/test/search-regression.test.ts | 35 ++ docs-mcp/test/tools.test.ts | 131 ++++++ docs-mcp/tsconfig.build.json | 9 + docs-mcp/tsconfig.json | 23 + {mcp => docs-mcp}/vitest.config.ts | 0 mcp/.gitignore | 4 - mcp/README.md | 64 --- mcp/scripts/build-data.mjs | 114 ----- mcp/scripts/docs.mjs | 169 -------- mcp/scripts/openapi.mjs | 258 ------------ mcp/scripts/snapshot-docs.mjs | 30 -- mcp/src/catalog.ts | 100 ----- mcp/src/docs.ts | 109 ----- mcp/src/index.ts | 34 -- mcp/src/server.ts | 147 ------- mcp/test/build-data.test.ts | 369 ---------------- mcp/test/docs-server.ts | 40 -- mcp/test/docs-tool.test.ts | 106 ----- mcp/test/global-setup.ts | 38 -- mcp/test/helpers.ts | 37 -- mcp/test/offline.test.ts | 31 -- mcp/test/run-build-data.ts | 18 - mcp/test/search-regression.test.ts | 56 --- mcp/test/tools.test.ts | 138 ------ mcp/tsconfig.json | 18 - scripts/check-skills.mjs | 172 -------- scripts/check-skills.mts | 225 ++++++++++ 64 files changed, 2674 insertions(+), 2080 deletions(-) create mode 100644 docs-mcp/.gitignore rename {mcp => docs-mcp}/LICENSE (100%) create mode 100644 docs-mcp/README.md rename {mcp => docs-mcp}/package-lock.json (99%) rename {mcp => docs-mcp}/package.json (58%) create mode 100644 docs-mcp/scripts/build-data.ts create mode 100644 docs-mcp/scripts/docs-sections.ts create mode 100644 docs-mcp/scripts/http.ts create mode 100644 docs-mcp/scripts/openapi-operations.ts create mode 100644 docs-mcp/scripts/script-runner.ts create mode 100644 docs-mcp/scripts/snapshot-docs.ts create mode 100644 docs-mcp/src/api-operations.ts create mode 100644 docs-mcp/src/data-files.ts rename mcp/src/docs-search.ts => docs-mcp/src/docs-index-options.ts (54%) create mode 100644 docs-mcp/src/docs-index.ts create mode 100644 docs-mcp/src/excerpt.ts create mode 100644 docs-mcp/src/index.ts create mode 100644 docs-mcp/src/operation-suggestions.ts create mode 100644 docs-mcp/src/server.ts create mode 100644 docs-mcp/src/tools/get-operation-tool.ts create mode 100644 docs-mcp/src/tools/list-operations-tool.ts create mode 100644 docs-mcp/src/tools/mcp-tool.ts create mode 100644 docs-mcp/src/tools/search-docs-tool.ts create mode 100644 docs-mcp/src/tools/tool-conventions.ts create mode 100644 docs-mcp/src/tools/tool-results.ts create mode 100644 docs-mcp/test/build-data.test.ts create mode 100644 docs-mcp/test/docs-server.ts create mode 100644 docs-mcp/test/excerpt.test.ts rename {mcp => docs-mcp}/test/fixtures/docs-corpus.json.gz (100%) rename {mcp => docs-mcp}/test/fixtures/openapi-v4.json (100%) create mode 100644 docs-mcp/test/global-setup.ts create mode 100644 docs-mcp/test/helpers.ts create mode 100644 docs-mcp/test/http.test.ts create mode 100644 docs-mcp/test/offline.test.ts create mode 100644 docs-mcp/test/run-build-data.ts create mode 100644 docs-mcp/test/search-docs-tool.test.ts create mode 100644 docs-mcp/test/search-regression.test.ts create mode 100644 docs-mcp/test/tools.test.ts create mode 100644 docs-mcp/tsconfig.build.json create mode 100644 docs-mcp/tsconfig.json rename {mcp => docs-mcp}/vitest.config.ts (100%) delete mode 100644 mcp/.gitignore delete mode 100644 mcp/README.md delete mode 100644 mcp/scripts/build-data.mjs delete mode 100644 mcp/scripts/docs.mjs delete mode 100644 mcp/scripts/openapi.mjs delete mode 100644 mcp/scripts/snapshot-docs.mjs delete mode 100644 mcp/src/catalog.ts delete mode 100644 mcp/src/docs.ts delete mode 100644 mcp/src/index.ts delete mode 100644 mcp/src/server.ts delete mode 100644 mcp/test/build-data.test.ts delete mode 100644 mcp/test/docs-server.ts delete mode 100644 mcp/test/docs-tool.test.ts delete mode 100644 mcp/test/global-setup.ts delete mode 100644 mcp/test/helpers.ts delete mode 100644 mcp/test/offline.test.ts delete mode 100644 mcp/test/run-build-data.ts delete mode 100644 mcp/test/search-regression.test.ts delete mode 100644 mcp/test/tools.test.ts delete mode 100644 mcp/tsconfig.json delete mode 100644 scripts/check-skills.mjs create mode 100644 scripts/check-skills.mts 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/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/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; From 2ac590e5d5c3208a75a389599e91a9c8c006cbc9 Mon Sep 17 00:00:00 2001 From: mendozagit Date: Thu, 22 Oct 2026 17:58:46 -0600 Subject: [PATCH 3/4] Skills y comandos dirigidos al desarrollador cliente MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plantilla común con CFDI/Factura de , reglas prescriptivas en lugar de narrar validaciones internas, sin datos internos (proveedor del PAC, ambientes internos, notas de auditoría) y con la nomenclatura de certificados: Certificado CSD (.cer), Llave privada CSD (.key), Certificado FIEL (.cer) y Llave privada FIEL (.key). Enlaces a la .md de cada sección y manifiestos en la versión 0.2.0. Verificado: check-skills sin hallazgos (184 enlaces) y plugin validate --strict. --- .claude-plugin/marketplace.json | 6 ++- .claude-plugin/plugin.json | 18 +++++++-- .cursor-plugin/plugin.json | 18 +++++++-- commands/primer-timbre.md | 12 +++--- commands/revisar-integracion.md | 4 +- skills/fiscalapi-carta-porte/SKILL.md | 30 +++++++------- skills/fiscalapi-ciclo-de-vida/SKILL.md | 20 +++++----- skills/fiscalapi-comercio-exterior/SKILL.md | 28 +++++++------ skills/fiscalapi-diagnostico/SKILL.md | 30 +++++++------- skills/fiscalapi-ingreso-egreso/SKILL.md | 32 +++++++++------ skills/fiscalapi-integracion/SKILL.md | 38 +++++++++--------- .../fiscalapi-integracion/references/sdks.md | 40 +++++++++---------- skills/fiscalapi-nomina/SKILL.md | 18 +++++---- skills/fiscalapi-pagos/SKILL.md | 24 +++++------ 14 files changed, 175 insertions(+), 143 deletions(-) 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/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/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`. From b5a6ed82810a7462fa514abdffd0468d16f9a0eb Mon Sep 17 00:00:00 2001 From: mendozagit Date: Thu, 22 Oct 2026 17:58:46 -0600 Subject: [PATCH 4/4] README y CONTRIBUTING para el desarrollador cliente MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Qué incluye el plugin, instalación por host con los comandos de la documentación oficial de cada uno, el servidor MCP de documentación, seguridad y soporte. CONTRIBUTING reúne la estructura del repo y la validación de los cambios. --- CONTRIBUTING.md | 57 +++++++++++++++++++++++++++++++++++++++++++ README.md | 64 ++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 112 insertions(+), 9 deletions(-) create mode 100644 CONTRIBUTING.md 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)