@dozimple/abap-adt 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,46 @@ Formato basado en [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/). V
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.1.0] - 2026-09-22
8
+
9
+ Tool nueva y contrato MCP ampliado (salida estructurada, progreso, cancelación) más resiliencia de conexión: el sprint 1 y parte del 2 del plan de mejoras.
10
+
11
+ ### Añadido
12
+ - **Progreso y cancelación** (sprint 2 del plan): las tools largas informan de su avance (`transport_diff` objeto a
13
+ objeto; `run_atc`, `where_used` y `run_unit_tests` por fase) y el cliente lo recibe como notificaciones de progreso
14
+ MCP si las pidió. La cancelación del cliente se respeta **entre pasos**: el paso en curso termina (una llamada ADT
15
+ no se puede abortar) y el siguiente ya no empieza. Una petición cancelada termina como `CANCELLED`, un tipo propio
16
+ que no cuenta como fallo del servidor ni abre el circuito.
17
+ - **Salida estructurada** (`outputSchema` / `structuredContent` de MCP), sprint 2 del plan: una tool puede declarar
18
+ `output` y devolver los mismos datos del texto de forma tipada, para que el cliente no tenga que interpretar la
19
+ prosa. El registro exige que toda respuesta no errónea de esas tools la traiga (si falta es error del servidor,
20
+ no un éxito a medias) y el SDK la valida contra el esquema antes de responder. Primeras tools: `sql_query` (filas,
21
+ columnas, valores hasta 500, `truncated`, avisos) y `syntax_check` (errores, avisos y mensajes con línea y
22
+ severidad). `docs/TOOLS.md` documenta el esquema de salida de cada una.
23
+ - **`revert_source`: volver a una versión anterior con confirmación.** Tras un `write_source` cuya activación falló,
24
+ el objeto queda con un borrador inactivo encima de la versión activa; la tool vuelve a escribir la versión elegida
25
+ (`active`: la última activa; `previous`: la anterior a la activa; `N`: una del historial de `object_versions`)
26
+ pasando por la misma vista previa, huella, bloqueo y orden que cualquier escritura. Nunca revierte por su cuenta:
27
+ un rollback automático que pisara una versión sin preguntar sería peor que dejar el objeto inactivo.
28
+ - **Resiliencia de conexión** (sprint 1 del plan de mejoras):
29
+ - **Sesión caducada renovada en las lecturas.** Si una tool de lectura recibe un token CSRF rechazado o un 401 en
30
+ una sesión que ya había entrado, el servidor descarta el cliente, vuelve a entrar y repite la lectura una vez;
31
+ la respuesta lo anota. Nunca en escrituras ni ejecuciones: el bloqueo se perdió con la sesión y un reintento
32
+ podría escribir dos veces. Una contraseña rechazada sigue fallando a la primera y sigue olvidándose.
33
+ - **Circuit breaker por sistema.** Tres fallos de red en un minuto abren el circuito de ESE sistema durante un
34
+ minuto: toda tool responde al instante «no se vuelve a intentar durante N s» en vez de esperar 120 s por llamada.
35
+ Los demás sistemas no se ven afectados; `sap_systems(check=true)` lo cierra y reintenta. Solo cuentan los fallos
36
+ de red reales de la librería, no los tiempos agotados propios.
37
+ - **Tiempo máximo por tool** (`timeoutMs`, por defecto 60 s; ATC y diff de orden 180 s, where-used y ABAP Unit
38
+ 120 s). Al vencer, error `NETWORK` con el tiempo y la sugerencia de acotar; no se reintenta.
39
+
40
+ ### Cambiado
41
+ - Insignia de **OpenSSF Best Practices (Passing)** en el README: el proyecto cumple los 67 criterios del nivel
42
+ Passing, incluidas las sugerencias, con la ficha pública en https://www.bestpractices.dev/projects/14759.
43
+ - El job de publicación usa Node 24, que ya trae npm >= 11.5.1: se quita la instalación global de npm, que no se
44
+ puede fijar por hash. Un paso comprueba la versión y falla antes de publicar si no la cumple. El build sigue en
45
+ Node 22, la versión mínima que soporta el servidor.
46
+
7
47
  ## [1.0.1] - 2026-09-22
8
48
 
9
49
  Versión de mantenimiento: seguimiento de la auditoría de seguridad, procedimiento de commit y pruebas por propiedades.
package/README.es.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  [English](README.md) · **Español**
6
6
 
7
- [![CI](../../actions/workflows/ci.yml/badge.svg)](../../actions/workflows/ci.yml) ![Node 22+](https://img.shields.io/badge/node-22%2B-2f7d32) ![MCP](https://img.shields.io/badge/MCP-stdio-5a4fcf) ![SAP](https://img.shields.io/badge/SAP-ECC%20%7C%20S%2F4HANA-0a6ed1) ![License](https://img.shields.io/badge/license-Apache--2.0-blue)
7
+ [![CI](../../actions/workflows/ci.yml/badge.svg)](../../actions/workflows/ci.yml) [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/14759/badge)](https://www.bestpractices.dev/projects/14759) ![Node 22+](https://img.shields.io/badge/node-22%2B-2f7d32) ![MCP](https://img.shields.io/badge/MCP-stdio-5a4fcf) ![SAP](https://img.shields.io/badge/SAP-ECC%20%7C%20S%2F4HANA-0a6ed1) ![License](https://img.shields.io/badge/license-Apache--2.0-blue)
8
8
 
9
9
  **IA que trabaja en tu SAP con las reglas de un consultor senior.**
10
10
 
@@ -61,7 +61,7 @@ El agente elige las tools, las encadena y responde con evidencia. Cada respuesta
61
61
  | [Consulta de datos](#g-datos) | Preguntar a las tablas con ABAP SQL, de solo lectura y sin tocar material de credenciales. | 2 |
62
62
  | [Diagnóstico de incidentes](#g-diagnostico) | Reunir en una conversación lo que antes exigía ST22, SM37, SLG1 y /IWFND/ERROR_LOG. | 4 |
63
63
  | [Documentación SAP](#g-documentacion) | Responder con la documentación oficial y comprobar qué sintaxis existe en cada release. | 7 |
64
- | [Escritura controlada](#g-escritura) | Guardar cambios solo en desarrollo, en la orden correcta y con la sintaxis verificada antes. | 4 |
64
+ | [Escritura controlada](#g-escritura) | Guardar cambios solo en desarrollo, en la orden correcta y con la sintaxis verificada antes. | 5 |
65
65
  | [DoZimple Transport Risk](#g-transport-risk) | Decidir si un pase entero puede ir a calidad o productivo, con el porqué en lenguaje de negocio. | 7 |
66
66
  | [Operación y crecimiento](#g-operacion) | Ver qué funciona en cada sistema y decidir con datos cuál es la siguiente tool. | 4 |
67
67
  <!-- groups:end -->
@@ -187,6 +187,7 @@ la **[referencia completa](docs/TOOLS.md)**.
187
187
  | Tool | Qué hace | Acceso |
188
188
  |---|---|---|
189
189
  | [`write_source`](docs/TOOLS.md#escritura) | **Guardar fuente en SAP.** Sustituye la fuente COMPLETA de un objeto existente (o de un include de clase), en la orden indicada. | escribe (DEV autorizado) |
190
+ | [`revert_source`](docs/TOOLS.md#escritura) | **Volver a una versión anterior.** Deshace un cambio escribiendo de nuevo una versión anterior del objeto: la última activa (para limpiar un borrador inactivo tras un write_source cuya activación falló), la anterior a la activa, o una concreta del historial de object_versions. | escribe (DEV autorizado) |
190
191
  | [`activate`](docs/TOOLS.md#escritura) | **Activar objeto.** Activa un objeto y devuelve los mensajes de SAP tal cual (errores con línea, avisos, objetos que quedan inactivos). | escribe (DEV autorizado) |
191
192
  | [`write_text_elements`](docs/TOOLS.md#escritura) | **Crear o cambiar símbolos de texto.** Añade o modifica símbolos de texto (o textos de selección) de un programa/clase/grupo, fusionando con los existentes: no borra los que no se mencionan. | escribe (DEV autorizado) |
192
193
  | [`create_transport`](docs/TOOLS.md#escritura) | **Crear orden de transporte.** Crea una orden workbench para el paquete de un objeto, ANTES de la primera edición, para que el cambio caiga en la orden del ticket y no en una tarea reutilizada. | escribe (DEV autorizado) |
@@ -291,7 +292,7 @@ Modelo de amenazas con STRIDE y OWASP Top 10 para aplicaciones LLM: **[docs/THRE
291
292
  Con la versión exacta y sin scripts de instalación, igual que las dependencias del propio proyecto:
292
293
 
293
294
  ```sh
294
- npm install -g --ignore-scripts @dozimple/abap-adt@1.0.1
295
+ npm install -g --ignore-scripts @dozimple/abap-adt@1.1.0
295
296
  PKG="$(npm root -g)/@dozimple/abap-adt"
296
297
  mkdir -p ~/.config/abap-adt-dozimple && chmod 700 ~/.config/abap-adt-dozimple
297
298
  cp "$PKG/config/systems.example.json" ~/.config/abap-adt-dozimple/systems.json # sistemas, roles y permisos
@@ -307,9 +308,9 @@ Registro en el cliente MCP:
307
308
  ```
308
309
 
309
310
  Cada versión se publica desde el CI con [procedencia de npm](https://docs.npmjs.com/generating-provenance-statements):
310
- `npm view @dozimple/abap-adt@1.0.1 dist.attestations` muestra la atestación, y la release de GitHub incluye el
311
+ `npm view @dozimple/abap-adt@1.1.0 dist.attestations` muestra la atestación, y la release de GitHub incluye el
311
312
  paquete, su bundle de Sigstore (`.sigstore.json`), el mismo bundle como procedencia in-toto (`.intoto.jsonl`)
312
- y el SBOM. Para comprobarlo: `gh attestation verify dozimple-abap-adt-1.0.1.tgz --repo <owner>/abap-adt-dozimple`.
313
+ y el SBOM. Para comprobarlo: `gh attestation verify dozimple-abap-adt-1.1.0.tgz --repo <owner>/abap-adt-dozimple`.
313
314
 
314
315
  ### Desde el código fuente
315
316
 
@@ -343,7 +344,7 @@ abap-adt-doZimple se construye sobre el trabajo de otros, y lo reconoce: cada to
343
344
  <!-- credits:start -->
344
345
  | Proyecto | Autor / titular | Licencia | Tipo | Usado en |
345
346
  |---|---|---|---|---|
346
- | [abap-adt-api](https://github.com/marcellourbani/abap-adt-api) | Marcello Urbani | MIT | dependencia | todas (núcleo), `transport_diff`, `transport_contents`, `inactive_objects`, `edit_preflight`, `run_atc` y 22 más |
347
+ | [abap-adt-api](https://github.com/marcellourbani/abap-adt-api) | Marcello Urbani | MIT | dependencia | todas (núcleo), `transport_diff`, `transport_contents`, `inactive_objects`, `edit_preflight`, `run_atc` y 23 más |
347
348
  | [Model Context Protocol TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk) | Model Context Protocol | MIT | dependencia | todas (núcleo) |
348
349
  | [mcp-sap-docs](https://github.com/marianfoo/mcp-sap-docs) | Marian Zeis (marianfoo) | Apache-2.0 | dependencia | `abap_feature_matrix`, `docs_search`, `docs_fetch`, `clean_core_objects`, `clean_core_object`, `abap_lint` y 1 más |
349
350
  | [abaplint](https://github.com/abaplint/abaplint) | Lars Hvam y contribuidores | MIT | dependencia | `abap_lint` |
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  **English** · [Español](README.es.md)
6
6
 
7
- [![CI](../../actions/workflows/ci.yml/badge.svg)](../../actions/workflows/ci.yml) ![Node 22+](https://img.shields.io/badge/node-22%2B-2f7d32) ![MCP](https://img.shields.io/badge/MCP-stdio-5a4fcf) ![SAP](https://img.shields.io/badge/SAP-ECC%20%7C%20S%2F4HANA-0a6ed1) ![License](https://img.shields.io/badge/license-Apache--2.0-blue)
7
+ [![CI](../../actions/workflows/ci.yml/badge.svg)](../../actions/workflows/ci.yml) [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/14759/badge)](https://www.bestpractices.dev/projects/14759) ![Node 22+](https://img.shields.io/badge/node-22%2B-2f7d32) ![MCP](https://img.shields.io/badge/MCP-stdio-5a4fcf) ![SAP](https://img.shields.io/badge/SAP-ECC%20%7C%20S%2F4HANA-0a6ed1) ![License](https://img.shields.io/badge/license-Apache--2.0-blue)
8
8
 
9
9
  **AI that works on your SAP system with the rules of a senior consultant.**
10
10
 
@@ -62,7 +62,7 @@ and **a failure is never presented as an empty result or as success**.
62
62
  | [Data queries](#g-datos) | Query tables with ABAP SQL, read-only, with sensitive and personal data protected. | 2 |
63
63
  | [Incident diagnosis](#g-diagnostico) | One conversation for what used to take ST22, SM37, SLG1 and /IWFND/ERROR_LOG. | 4 |
64
64
  | [SAP documentation](#g-documentacion) | Answer from official documentation and check which syntax exists in each release. | 7 |
65
- | [Controlled writes](#g-escritura) | Save changes only in development, in the right transport, previewed and confirmed by a human. | 4 |
65
+ | [Controlled writes](#g-escritura) | Save changes only in development, in the right transport, previewed and confirmed by a human. | 5 |
66
66
  | [DoZimple Transport Risk](#g-transport-risk) | Decide whether a whole release can go to QA or production, with the why in business terms. | 7 |
67
67
  | [Operations and growth](#g-operacion) | See what works on each system and decide the next tool with data. | 4 |
68
68
  <!-- groups:end -->
@@ -188,6 +188,7 @@ Summary per group; each tool's details — parameters, types, defaults, requirem
188
188
  | Tool | What it does | Access |
189
189
  |---|---|---|
190
190
  | [`write_source`](docs/TOOLS.md#escritura) | **Save source to SAP.** Replaces the FULL source of an existing object (or class include) in the given transport, after a preview with syntax check and diff and a human confirmation. | writes (authorized DEV) |
191
+ | [`revert_source`](docs/TOOLS.md#escritura) | **Revert to an earlier version.** Writes back an earlier version of an object (the last active one, the one before it, or a numbered one from object_versions) through the same preview, fingerprint, lock and transport as write_source; never reverts on its own. | writes (authorized DEV) |
191
192
  | [`activate`](docs/TOOLS.md#escritura) | **Activate object.** Activates an object and returns SAP's messages as they are (errors with line, warnings, objects left inactive). | writes (authorized DEV) |
192
193
  | [`write_text_elements`](docs/TOOLS.md#escritura) | **Create or change text symbols.** Adds or changes text symbols (or selection texts) of a program/class/group, merging with the existing ones: nothing not mentioned is deleted. | writes (authorized DEV) |
193
194
  | [`create_transport`](docs/TOOLS.md#escritura) | **Create transport request.** Creates a workbench request for an object's package BEFORE the first edit, so the change lands in the ticket's transport and not in a reused task. | writes (authorized DEV) |
@@ -291,7 +292,7 @@ Designed to pass a Security and Basis review without exceptions. Details: **[SEC
291
292
  Pinned to an exact version, without install scripts, like the project's own dependencies:
292
293
 
293
294
  ```sh
294
- npm install -g --ignore-scripts @dozimple/abap-adt@1.0.1
295
+ npm install -g --ignore-scripts @dozimple/abap-adt@1.1.0
295
296
  PKG="$(npm root -g)/@dozimple/abap-adt"
296
297
  mkdir -p ~/.config/abap-adt-dozimple && chmod 700 ~/.config/abap-adt-dozimple
297
298
  cp "$PKG/config/systems.example.json" ~/.config/abap-adt-dozimple/systems.json # systems, roles and permissions
@@ -307,9 +308,9 @@ MCP client registration:
307
308
  ```
308
309
 
309
310
  Every release is published from CI with [npm provenance](https://docs.npmjs.com/generating-provenance-statements):
310
- `npm view @dozimple/abap-adt@1.0.1 dist.attestations` shows the attestation, and the GitHub release carries the
311
+ `npm view @dozimple/abap-adt@1.1.0 dist.attestations` shows the attestation, and the GitHub release carries the
311
312
  tarball, its Sigstore bundle (`.sigstore.json`), the same bundle as in-toto provenance (`.intoto.jsonl`) and the
312
- SBOM. To check it: `gh attestation verify dozimple-abap-adt-1.0.1.tgz --repo <owner>/abap-adt-dozimple`.
313
+ SBOM. To check it: `gh attestation verify dozimple-abap-adt-1.1.0.tgz --repo <owner>/abap-adt-dozimple`.
313
314
 
314
315
  ### From source
315
316
 
@@ -344,7 +345,7 @@ abap-adt-doZimple is built on other people's work, and says so: each tool lists
344
345
  <!-- credits:start -->
345
346
  | Project | Author / holder | License | Type | Used in |
346
347
  |---|---|---|---|---|
347
- | [abap-adt-api](https://github.com/marcellourbani/abap-adt-api) | Marcello Urbani | MIT | dependency | all (core), `transport_diff`, `transport_contents`, `inactive_objects`, `edit_preflight`, `run_atc` and 22 more |
348
+ | [abap-adt-api](https://github.com/marcellourbani/abap-adt-api) | Marcello Urbani | MIT | dependency | all (core), `transport_diff`, `transport_contents`, `inactive_objects`, `edit_preflight`, `run_atc` and 23 more |
348
349
  | [Model Context Protocol TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk) | Model Context Protocol | MIT | dependency | all (core) |
349
350
  | [mcp-sap-docs](https://github.com/marianfoo/mcp-sap-docs) | Marian Zeis (marianfoo) | Apache-2.0 | dependency | `abap_feature_matrix`, `docs_search`, `docs_fetch`, `clean_core_objects`, `clean_core_object`, `abap_lint` and 1 more |
350
351
  | [abaplint](https://github.com/abaplint/abaplint) | Lars Hvam and contributors | MIT | dependency | `abap_lint` |
@@ -48,6 +48,7 @@ export const TOOLS_EN = {
48
48
  abap_lint: "**abaplint on a snippet.** Runs abaplint locally (code never leaves the machine) on an ABAP snippet or source.",
49
49
  docs_community_search: "**Search SAP Community.** Searches SAP Community (blogs and questions) by error message, class or concept.",
50
50
  write_source: "**Save source to SAP.** Replaces the FULL source of an existing object (or class include) in the given transport, after a preview with syntax check and diff and a human confirmation.",
51
+ revert_source: "**Revert to an earlier version.** Writes back an earlier version of an object (the last active one, the one before it, or a numbered one from object_versions) through the same preview, fingerprint, lock and transport as write_source; never reverts on its own.",
51
52
  activate: "**Activate object.** Activates an object and returns SAP's messages as they are (errors with line, warnings, objects left inactive).",
52
53
  write_text_elements: "**Create or change text symbols.** Adds or changes text symbols (or selection texts) of a program/class/group, merging with the existing ones: nothing not mentioned is deleted.",
53
54
  create_transport: "**Create transport request.** Creates a workbench request for an object's package BEFORE the first edit, so the change lands in the ticket's transport and not in a reused task.",
@@ -100,6 +100,7 @@ export const GROUPS = [
100
100
  pitch: "Guardar cambios solo en desarrollo, en la orden correcta y con la sintaxis verificada antes.",
101
101
  tools: [
102
102
  { name: "write_source", credits: ADT },
103
+ { name: "revert_source", credits: ADT },
103
104
  { name: "activate", credits: ADT },
104
105
  { name: "write_text_elements", credits: ADT },
105
106
  { name: "create_transport", credits: ADT },
@@ -8,6 +8,10 @@ import { normalizeValue, wrapSql } from "./sql.js";
8
8
  const CACHE_DIR = join(homedir(), ".cache", "abap-adt-dozimple");
9
9
  const DISCOVERY_TTL_MS = 7 * 24 * 3600 * 1000;
10
10
  const REQUEST_TIMEOUT_MS = 120_000;
11
+ /** Circuit breaker: N fallos de red en la ventana abren el circuito durante CIRCUIT_OPEN_MS. */
12
+ const CIRCUIT_FAILURES = 3;
13
+ const CIRCUIT_WINDOW_MS = 60_000;
14
+ const CIRCUIT_OPEN_MS = 60_000;
11
15
  /**
12
16
  * TLS: con caFile se verifica contra ese certificado (lo correcto para un
13
17
  * sistema con certificado propio); allowSelfSigned desactiva la verificación
@@ -30,9 +34,44 @@ export class SapConnection {
30
34
  reader;
31
35
  caps;
32
36
  basisRelease;
37
+ networkFailures = [];
38
+ openUntil = 0;
33
39
  constructor(system) {
34
40
  this.system = system;
35
41
  }
42
+ /** Olvida el cliente de lectura (sesión caducada): la siguiente llamada vuelve a entrar. */
43
+ resetReader() {
44
+ const old = this.reader;
45
+ this.reader = undefined;
46
+ old?.then((c) => c.logout()).catch(() => undefined);
47
+ }
48
+ /**
49
+ * Tres fallos de red en un minuto abren el circuito: durante un minuto ninguna tool intenta nada contra este
50
+ * sistema y responde al instante. Un sistema caído (VPN, host) dejaba de responder 120 s por cada llamada.
51
+ */
52
+ noteNetworkFailure(now = Date.now()) {
53
+ this.networkFailures = this.networkFailures.filter((t) => now - t < CIRCUIT_WINDOW_MS);
54
+ this.networkFailures.push(now);
55
+ if (this.networkFailures.length >= CIRCUIT_FAILURES) {
56
+ this.openUntil = now + CIRCUIT_OPEN_MS;
57
+ this.networkFailures = [];
58
+ }
59
+ }
60
+ /** Segundos que faltan para volver a intentar, o 0 si el circuito está cerrado. */
61
+ circuitOpenFor(now = Date.now()) {
62
+ return this.openUntil > now ? Math.ceil((this.openUntil - now) / 1000) : 0;
63
+ }
64
+ /** Cierra el circuito a mano (sap_systems con check): se vuelve a intentar ya. */
65
+ resetCircuit() {
66
+ this.openUntil = 0;
67
+ this.networkFailures = [];
68
+ }
69
+ assertCircuitClosed() {
70
+ const s = this.circuitOpenFor();
71
+ if (s) {
72
+ throw new ToolError("NETWORK", `${this.system.id} no respondió ${CIRCUIT_FAILURES} veces seguidas: no se vuelve a intentar durante ${s} s.`, "sap_systems(check=true) lo reintenta ahora mismo.");
73
+ }
74
+ }
36
75
  async newClient() {
37
76
  const s = this.system;
38
77
  const password = await getPassword(s);
@@ -53,6 +92,7 @@ export class SapConnection {
53
92
  }
54
93
  /** Cliente de lectura, reutilizado entre llamadas. */
55
94
  async adt() {
95
+ this.assertCircuitClosed();
56
96
  if (!this.reader) {
57
97
  this.reader = this.newClient().then((c) => this.login(c));
58
98
  this.reader.catch(() => (this.reader = undefined));
@@ -61,6 +101,7 @@ export class SapConnection {
61
101
  }
62
102
  /** Ejecuta fn con una sesión stateful propia y la cierra siempre. */
63
103
  async stateful(fn) {
104
+ this.assertCircuitClosed();
64
105
  const c = await this.login(await this.newClient());
65
106
  c.stateful = session_types.stateful;
66
107
  try {
@@ -19,6 +19,9 @@ export function normalizeError(e, systemId) {
19
19
  if (e instanceof ToolError)
20
20
  return e;
21
21
  const any = e;
22
+ // signal.throwIfAborted() entre pasos de una tool larga: cancelación del cliente, no fallo del servidor.
23
+ if (any?.name === "AbortError")
24
+ return new ToolError("CANCELLED", "Cancelado por el cliente antes de terminar.");
22
25
  const code = errCode(any);
23
26
  if (code && NETWORK_CODES.has(code)) {
24
27
  return new ToolError("NETWORK", `No se llega a ${systemId ?? "SAP"} (${code}).`, "¿Está levantada la VPN de ese cliente?");
@@ -50,6 +53,16 @@ export function normalizeError(e, systemId) {
50
53
  const message = sanitizeMessage(any?.message ? String(any.message) : String(e));
51
54
  return new ToolError("INTERNAL", message || "Error sin mensaje");
52
55
  }
56
+ /**
57
+ * Sesión caducada a mitad de una lectura: token CSRF rechazado, o 401 en una sesión que ya había entrado. El registro
58
+ * la renueva y repite la lectura UNA vez; nunca una escritura (el bloqueo se perdió con la sesión).
59
+ */
60
+ export function isSessionExpired(e) {
61
+ if (isCsrfError(e))
62
+ return true;
63
+ const st = e;
64
+ return (isHttpError(e) && st?.status === 401) || (isAdtError(e) && st?.err === 401);
65
+ }
53
66
  export function renderError(te) {
54
67
  const labels = {
55
68
  NETWORK: "Sin conexión",
@@ -60,6 +73,7 @@ export function renderError(te) {
60
73
  CAPABILITY: "No disponible en este sistema",
61
74
  MODULE: "Módulo no habilitado",
62
75
  INPUT: "Parámetros inválidos",
76
+ CANCELLED: "Cancelado",
63
77
  INTERNAL: "Error interno",
64
78
  };
65
79
  return `${labels[te.kind]}: ${te.message}${te.hint ? `\n${te.hint}` : ""}\n` +
@@ -3,11 +3,11 @@ import { join } from "node:path";
3
3
  import { pathToFileURL } from "node:url";
4
4
  import { z } from "zod";
5
5
  import { canWrite, resolveSystem } from "./config.js";
6
- import { normalizeError, renderError, ToolError } from "./errors.js";
6
+ import { isSessionExpired, normalizeError, renderError, ToolError } from "./errors.js";
7
7
  import { budget } from "./output.js";
8
8
  import { appendAudit, auditArgs } from "./audit.js";
9
9
  import { consumeTokenWithState, issueToken } from "./confirm.js";
10
- import { renderNotes, withNotes } from "./notes.js";
10
+ import { addNote, renderNotes, withNotes } from "./notes.js";
11
11
  import { assertAccess } from "./policy.js";
12
12
  import { recordUsage } from "./telemetry.js";
13
13
  /**
@@ -182,6 +182,15 @@ export async function invoke(def, rawArgs, env) {
182
182
  throw new ToolError("INTERNAL", "No hay componentes auxiliares en este servidor.");
183
183
  return env.sidecars.get(name);
184
184
  },
185
+ signal: env.signal,
186
+ progress(message, current, total) {
187
+ try {
188
+ env.progress?.(message, current, total);
189
+ }
190
+ catch {
191
+ /* el progreso es cortesía: un fallo al notificar nunca rompe la tool */
192
+ }
193
+ },
185
194
  };
186
195
  const head = resolved ? `Sistema: ${resolved.system.id} (${resolved.source})\n${SAP_DATA_NOTE}\n\n` : "";
187
196
  if (resolved && (def.access === "write" || def.access === "exec")) {
@@ -218,14 +227,23 @@ export async function invoke(def, rawArgs, env) {
218
227
  throw new ToolError("INTERNAL", `No se ejecutó: no se pudo escribir el registro de auditoría (${e.message}).`);
219
228
  }
220
229
  }
221
- const { result: res, notes } = await withNotes(() => def.run(args, ctx));
230
+ const { result: res, notes } = await withNotes(() => runGuarded(def, args, ctx, sapConn, systemId));
222
231
  const text = renderNotes(notes) + (typeof res === "string" ? res : res.text);
223
232
  const isError = typeof res === "string" ? false : !!res.isError;
224
- outcome = { text: head + budget(text), isError, kind: isError ? "RESULT" : undefined, system: systemId };
233
+ const structured = typeof res === "string" ? undefined : res.structured;
234
+ // Una tool con esquema de salida que responde bien sin datos estructurados es un bug: mejor verlo aquí que
235
+ // como error críptico del SDK en el cliente.
236
+ if (def.output && !isError && !structured)
237
+ throw new ToolError("INTERNAL", `${def.name} declara salida estructurada y no la devolvió.`);
238
+ outcome = { text: head + budget(text), isError, kind: isError ? "RESULT" : undefined, system: systemId, structured };
225
239
  }
226
240
  }
227
241
  catch (e) {
228
242
  let te = normalizeError(e, systemId);
243
+ // Solo los fallos de red REALES (de la librería) cuentan para el circuito: ni un timeout nuestro ni el propio
244
+ // aviso de circuito abierto.
245
+ if (te.kind === "NETWORK" && !(e instanceof ToolError) && sapConn)
246
+ sapConn.noteNetworkFailure();
229
247
  // Un 404 de SAP (no un «objeto no existe» nuestro) sobre un endpoint que el
230
248
  // discovery tampoco lista: ahora sí hay evidencia de que falta la función.
231
249
  if (te.kind === "NOT_FOUND" && !(e instanceof ToolError) && missing.length && sapConn) {
@@ -251,6 +269,50 @@ export async function invoke(def, rawArgs, env) {
251
269
  });
252
270
  return outcome;
253
271
  }
272
+ const DEFAULT_TIMEOUT_MS = 60_000;
273
+ function withTimeout(p, ms, what, signal) {
274
+ let timer;
275
+ let onAbort;
276
+ const limit = new Promise((_, reject) => {
277
+ if (signal) {
278
+ onAbort = () => reject(new ToolError("CANCELLED", `${what}: cancelado por el cliente.`));
279
+ if (signal.aborted)
280
+ onAbort();
281
+ else
282
+ signal.addEventListener("abort", onAbort, { once: true });
283
+ }
284
+ timer = setTimeout(() => reject(new ToolError("NETWORK", `${what}: tiempo agotado (${ms >= 1000 ? `${Math.round(ms / 1000)} s` : `${ms} ms`}).`, "Acota la petición (menos objetos, más filtro) o repite más tarde.")), ms);
285
+ });
286
+ return Promise.race([p, limit]).finally(() => {
287
+ clearTimeout(timer);
288
+ if (signal && onAbort)
289
+ signal.removeEventListener("abort", onAbort);
290
+ });
291
+ }
292
+ /**
293
+ * Ejecuta la tool con su tiempo máximo y, si la sesión de lectura había caducado (CSRF o 401 tras haber entrado),
294
+ * la renueva y repite UNA vez. Nunca repite escrituras ni ejecuciones: el bloqueo se perdió con la sesión y un
295
+ * reintento podría escribir dos veces.
296
+ */
297
+ async function runGuarded(def, args, ctx, sapConn, systemId) {
298
+ const ms = def.timeoutMs ?? DEFAULT_TIMEOUT_MS;
299
+ const attempt = () => {
300
+ if (ctx.signal?.aborted)
301
+ throw new ToolError("CANCELLED", `${def.name}: cancelado por el cliente.`);
302
+ return withTimeout(def.run(args, ctx), ms, def.name, ctx.signal);
303
+ };
304
+ try {
305
+ return await attempt();
306
+ }
307
+ catch (e) {
308
+ if (def.access === "read" && sapConn && isSessionExpired(e)) {
309
+ sapConn.resetReader();
310
+ addNote(`La sesión con ${systemId ?? "SAP"} había caducado: se renovó y la lectura se repitió.`);
311
+ return await attempt();
312
+ }
313
+ throw e;
314
+ }
315
+ }
254
316
  /** Elicitación de formulario, si el cliente la anuncia (ABAP_DZ_CONFIRM=token la desactiva). */
255
317
  function elicitFor(server) {
256
318
  const caps = server.server.getClientCapabilities()?.elicitation;
@@ -313,10 +375,21 @@ export function registerAll(server, defs, config, pool, sidecars) {
313
375
  title: def.title,
314
376
  description: describe(def, config),
315
377
  inputSchema,
378
+ ...(def.output ? { outputSchema: z.object(def.output) } : {}),
316
379
  annotations: annotationsFor(def),
317
- }, (async (args) => {
318
- const r = await invoke(def, args ?? {}, { config, pool, tools: defs, sidecars, elicit: elicitFor(server) });
319
- return { content: [{ type: "text", text: r.text }], isError: r.isError };
380
+ }, (async (args, extra) => {
381
+ const token = extra?._meta?.progressToken;
382
+ const progress = token !== undefined && extra?.sendNotification
383
+ ? (message, current, total) => {
384
+ void extra.sendNotification({ method: "notifications/progress", params: { progressToken: token, progress: current ?? 0, total, message } }).catch(() => undefined);
385
+ }
386
+ : undefined;
387
+ const r = await invoke(def, args ?? {}, { config, pool, tools: defs, sidecars, elicit: elicitFor(server), signal: extra?.signal, progress });
388
+ return {
389
+ content: [{ type: "text", text: r.text }],
390
+ isError: r.isError,
391
+ ...(r.structured ? { structuredContent: r.structured } : {}),
392
+ };
320
393
  }));
321
394
  published.push(def.name);
322
395
  }
@@ -0,0 +1,111 @@
1
+ import { z } from "zod";
2
+ import { isError, renderSyntax, syntaxCheck } from "../../core/checks.js";
3
+ import { stateOf } from "../../core/confirm.js";
4
+ import { diffLines, unified } from "../../core/diff.js";
5
+ import { ToolError } from "../../core/errors.js";
6
+ import { CLASS_INCLUDES, resolveObject, sourceUrl, TYPE_HELP } from "../../core/objects.js";
7
+ import { assertTrkorr } from "../../core/policy.js";
8
+ import { versionNumber } from "../../core/revisions.js";
9
+ import { defineTool } from "../../core/tool.js";
10
+ import { transportWarnings } from "../../core/transport.js";
11
+ import writeSource from "./write_source.js";
12
+ /**
13
+ * Deshacer con confirmación, nunca automático. Tras un write_source cuya activación falló, el objeto queda con un
14
+ * borrador inactivo (versión 99999 de ADT) encima de la versión activa (00000). Esta tool escribe de nuevo la
15
+ * versión elegida —la activa, la anterior a la activa, o una concreta del historial— pasando por la misma vista
16
+ * previa, confirmación, bloqueo y orden que cualquier escritura. Un rollback que pisara una versión sin preguntar
17
+ * sería peor que dejar el objeto inactivo.
18
+ */
19
+ const TARGET_HELP = "«active»: la última versión activa (deshace un borrador inactivo, p. ej. un write_source cuya activación falló). " +
20
+ "«previous»: la versión anterior a la activa (deshace la última activación). " +
21
+ "Un número N: la versión N tal como la lista object_versions.";
22
+ function pickRevision(revs, target) {
23
+ if (typeof target === "number") {
24
+ const rev = revs[target - 1];
25
+ if (!rev)
26
+ throw new ToolError("INPUT", `Solo hay ${revs.length} versiones (object_versions las lista).`);
27
+ return { rev, label: `versión ${target} del historial` };
28
+ }
29
+ const active = revs.find((r) => versionNumber(r) === "00000");
30
+ if (target === "active") {
31
+ if (!active)
32
+ throw new ToolError("NOT_FOUND", "SAP no devuelve una versión activa de este objeto.");
33
+ return { rev: active, label: "última versión activa" };
34
+ }
35
+ const numbered = revs
36
+ .filter((r) => /^\d{5}$/.test(versionNumber(r)) && !["00000", "99999"].includes(versionNumber(r)))
37
+ .sort((a, b) => (Date.parse(b.date) || 0) - (Date.parse(a.date) || 0) || Number(versionNumber(b)) - Number(versionNumber(a)));
38
+ if (!numbered.length)
39
+ throw new ToolError("NOT_FOUND", "No hay ninguna versión grabada anterior a la activa.");
40
+ return { rev: numbered[0], label: "versión anterior a la activa" };
41
+ }
42
+ async function chosenSource(c, obj, include, target) {
43
+ const revs = await c.revisions(obj.uri, obj.type.startsWith("CLAS") ? include : undefined);
44
+ if (!revs.length)
45
+ throw new ToolError("NOT_FOUND", `${obj.name}: SAP no devuelve versiones para este objeto.`);
46
+ const { rev, label } = pickRevision(revs, target);
47
+ const hasDraft = revs.some((r) => versionNumber(r) === "99999");
48
+ return { rev, label, hasDraft, source: await c.getObjectSource(rev.uri) };
49
+ }
50
+ const input = {
51
+ object_name: z.string().min(1),
52
+ object_type: z.string().optional().describe(TYPE_HELP),
53
+ include: z.enum(CLASS_INCLUDES).default("main"),
54
+ target: z.union([z.enum(["active", "previous"]), z.number().int().min(1)]).default("active").describe(TARGET_HELP),
55
+ transport: z.string().optional().describe("Orden (o tarea) donde debe ir la reversión. Obligatoria salvo objetos locales"),
56
+ activate: z.boolean().default(true),
57
+ };
58
+ export default defineTool({
59
+ name: "revert_source",
60
+ title: "Volver a una versión anterior",
61
+ description: "Deshace un cambio escribiendo de nuevo una versión anterior del objeto: la última activa (para limpiar un " +
62
+ "borrador inactivo tras un write_source cuya activación falló), la anterior a la activa, o una concreta del " +
63
+ "historial de object_versions. Pasa por la misma vista previa, confirmación, bloqueo y orden que write_source: " +
64
+ "nunca revierte por su cuenta. Solo objetos de código fuente.",
65
+ access: "write",
66
+ input,
67
+ async preview({ object_name, object_type, include, target, transport, activate }, { sap, system }) {
68
+ const requested = transport ? assertTrkorr(transport) : undefined;
69
+ const c = await sap.adt();
70
+ const obj = await resolveObject(c, object_name, object_type);
71
+ const url = await sourceUrl(c, obj, include);
72
+ const current = await c.getObjectSource(url);
73
+ const { rev, label, hasDraft, source } = await chosenSource(c, obj, include, target);
74
+ const out = [
75
+ `${obj.name} (${obj.type})${include !== "main" ? ` · include ${include}` : ""} · paquete ${obj.packageName ?? "?"}`,
76
+ `Volver a: ${label} · ${rev.date} · ${rev.author}${rev.versionTitle ? ` · ${rev.versionTitle}` : ""}`,
77
+ `Orden: ${requested ?? "ninguna indicada (solo vale para objetos locales)"} · activar después: ${activate ? "sí" : "no"}`,
78
+ hasDraft ? "Estado: el objeto tiene un borrador inactivo (cambios guardados sin activar)." : "Estado: sin borrador inactivo.",
79
+ ];
80
+ if (target === "active" && !hasDraft)
81
+ out.push("Aviso: no hay borrador que deshacer; la versión activa ya es la que se ve.");
82
+ if (requested) {
83
+ const warn = await transportWarnings(sap, requested, system.user);
84
+ out.push(warn.length ? `⚠ AVISOS DE LA ORDEN ${requested}:\n${warn.map((w) => " ⚠ " + w).join("\n")}` : `Orden ${requested}: modificable, con destino y del usuario de esta conexión.`);
85
+ }
86
+ const msgs = await syntaxCheck(c, obj, url, source);
87
+ out.push(msgs.some(isError) ? `Sintaxis de la versión elegida: CON ERRORES, la escritura se detendrá.\n${renderSyntax(msgs)}` : `Sintaxis de la versión elegida: sin errores${msgs.length ? ` (${msgs.length} avisos)` : ""}.`);
88
+ const ops = diffLines(current, source);
89
+ if (!ops)
90
+ out.push("", "Cambio: el objeto se reescribe casi entero (demasiadas diferencias para mostrarlas como diff).");
91
+ else {
92
+ const u = unified(ops, 3);
93
+ const lines = u.text.split("\n");
94
+ out.push("", u.hunks ? `Cambio respecto a lo que hay ahora: +${u.added} −${u.removed} en ${u.hunks} bloques` : "Cambio: ninguno (la versión elegida es idéntica a lo que hay ahora).");
95
+ if (u.hunks)
96
+ out.push(lines.slice(0, 400).join("\n") + (lines.length > 400 ? `\n[… ${lines.length - 400} líneas más de diff]` : ""));
97
+ }
98
+ return { text: out.join("\n"), state: stateOf(current) };
99
+ },
100
+ async run({ object_name, object_type, include, target, transport, activate }, ctx) {
101
+ const c = await ctx.sap.adt();
102
+ const obj = await resolveObject(c, object_name, object_type);
103
+ const { label, source } = await chosenSource(c, obj, include, target);
104
+ // La escritura real es la de write_source: mismo bloqueo, misma comprobación de que nada cambió desde la vista
105
+ // previa (ctx.confirmedState), misma decisión de orden y misma activación.
106
+ const r = await writeSource.run({ object_name, object_type, include, source, transport, activate, skip_syntax_check: false }, ctx);
107
+ const text = typeof r === "string" ? r : r.text;
108
+ return { text: `Revertido a la ${label}.\n${text}`, isError: typeof r === "string" ? false : !!r.isError };
109
+ },
110
+ });
111
+ //# sourceMappingURL=revert_source.js.map
@@ -10,9 +10,10 @@ import { defineTool } from "../../core/tool.js";
10
10
  const decode = decodeEntities;
11
11
  const strip = htmlToText;
12
12
  /** Ejecuta el ATC del alcance y lo recuerda bajo ese alcance. */
13
- async function execute(c, sap, systemId, t, o) {
13
+ async function execute(c, sap, systemId, t, o, ctx) {
14
14
  const variant = o.variant ?? (await defaultVariant(c));
15
15
  let res;
16
+ ctx?.progress?.(`ATC de ${t.scope} con la variante ${variant}…`);
16
17
  try {
17
18
  res = await runAtc(c, t.uri, variant, o.max_findings, o.include_exempted);
18
19
  res.scope = t.scope;
@@ -35,6 +36,8 @@ async function execute(c, sap, systemId, t, o) {
35
36
  const uris = (await Promise.all(refs.map((r) => resolveByTypePrefix(c, r.name, r.types)))).filter(Boolean).map((r) => r.uri);
36
37
  if (!uris.length)
37
38
  throw new ToolError("NOT_FOUND", `La orden ${tr} no contiene objetos que el ATC pueda revisar.`);
39
+ ctx?.signal?.throwIfAborted();
40
+ ctx?.progress?.(`Este release no admite la orden como conjunto: ATC sobre sus ${uris.length} objetos en una corrida…`);
38
41
  res = await runAtc(c, uris, variant, o.max_findings, o.include_exempted);
39
42
  res.scope = `orden ${tr} (sus ${uris.length} objetos: este release no admite la orden como conjunto ATC)`;
40
43
  }
@@ -44,6 +47,7 @@ async function execute(c, sap, systemId, t, o) {
44
47
  }
45
48
  export default defineTool({
46
49
  name: "run_atc",
50
+ timeoutMs: 180_000,
47
51
  title: "Ejecutar ATC",
48
52
  description: "Ejecuta el ATC sobre un objeto o una orden de transporte y lista los hallazgos numerados (prioridad, línea, " +
49
53
  "check, mensaje), con los totales P1/P2/P3 que da SAP. El resultado queda recordado POR OBJETO U ORDEN: " +
@@ -66,7 +70,8 @@ export default defineTool({
66
70
  .optional()
67
71
  .describe("Documentación del hallazgo N del ATC del objeto u orden indicados (sin ellos: del último ATC, y lo dice)"),
68
72
  },
69
- async run(a, { sap, system }) {
73
+ async run(a, ctx) {
74
+ const { sap, system } = ctx;
70
75
  const c = await sap.adt();
71
76
  // El alcance (objeto u orden nombrados) se resuelve antes que nada: explain=N es SIEMPRE de ese alcance.
72
77
  let target;
@@ -83,7 +88,7 @@ export default defineTool({
83
88
  let run = target ? recallRun(system.id, target.key) : recallRun(system.id);
84
89
  let ranNow = false;
85
90
  if (!run && target) {
86
- run = await execute(c, sap, system.id, target, opts);
91
+ run = await execute(c, sap, system.id, target, opts, ctx);
87
92
  ranNow = true;
88
93
  }
89
94
  if (!run)
@@ -99,7 +104,7 @@ export default defineTool({
99
104
  }
100
105
  if (!target)
101
106
  throw new ToolError("INPUT", "Indica object_name o transport.");
102
- const res = await execute(c, sap, system.id, target, opts);
107
+ const res = await execute(c, sap, system.id, target, opts, ctx);
103
108
  const shown = res.findings.filter((f) => !a.priorities || a.priorities.includes(f.priority));
104
109
  const byPrio = [1, 2, 3, 4].map((p) => res.findings.filter((f) => f.priority === p).length);
105
110
  const stats = res.stats ? `SAP: P1 ${res.stats.p1} · P2 ${res.stats.p2} · P3 ${res.stats.p3}` : `P1 ${byPrio[0]} · P2 ${byPrio[1]} · P3 ${byPrio[2]}`;
@@ -9,6 +9,7 @@ import { defineTool } from "../../core/tool.js";
9
9
  export const SAFE_TEST_FLAGS = { harmless: true, dangerous: false, critical: false, short: true, medium: false, long: false };
10
10
  export default defineTool({
11
11
  name: "run_unit_tests",
12
+ timeoutMs: 120_000,
12
13
  title: "Ejecutar ABAP Unit",
13
14
  description: "Ejecuta los tests ABAP Unit de una clase o programa y devuelve el resultado por método, con el detalle de cada " +
14
15
  "fallo. Solo corre tests RISK LEVEL HARMLESS y DURATION SHORT. Si no hay clases de test lo dice: cero tests no es un éxito.",
@@ -20,9 +21,11 @@ export default defineTool({
20
21
  object_name: z.string().min(1),
21
22
  object_type: z.string().default("CLAS").describe(TYPE_HELP),
22
23
  },
23
- async run({ object_name, object_type }, { sap }) {
24
+ async run({ object_name, object_type }, ctx) {
25
+ const { sap } = ctx;
24
26
  const c = await sap.adt();
25
27
  const obj = await resolveObject(c, object_name, object_type);
28
+ ctx.progress?.(`Ejecutando ABAP Unit de ${obj.name} (solo tests harmless y short)…`);
26
29
  const classes = await c.unitTestRun(obj.uri, { ...SAFE_TEST_FLAGS });
27
30
  if (!classes.length)
28
31
  return { text: `${obj.name}: no se encontraron clases de test. No se ejecutó nada.`, isError: true };
@@ -2,6 +2,8 @@ import { z } from "zod";
2
2
  import { guardedQuery } from "../../core/datapolicy.js";
3
3
  import { tsv } from "../../core/output.js";
4
4
  import { defineTool } from "../../core/tool.js";
5
+ /** La salida estructurada duplica el contenido del texto: se acota para no doblar respuestas enormes. */
6
+ const STRUCTURED_ROWS = 500;
5
7
  export default defineTool({
6
8
  name: "sql_query",
7
9
  title: "Consulta ABAP SQL",
@@ -14,12 +16,26 @@ export default defineTool({
14
16
  query: z.string().min(1).describe("SELECT ... FROM ... WHERE ..."),
15
17
  max_rows: z.number().int().min(1).max(5000).default(100),
16
18
  },
19
+ output: {
20
+ rows: z.number().int().describe("Filas devueltas"),
21
+ columns: z.array(z.string()),
22
+ values: z.array(z.record(z.unknown())).describe(`Filas como objetos columna→valor (hasta ${STRUCTURED_ROWS}; el texto las trae todas)`),
23
+ truncated: z.boolean().describe("true si values no incluye todas las filas"),
24
+ notes: z.array(z.string()).describe("Avisos de la política de datos (enmascarado, tope de filas)"),
25
+ },
17
26
  async run({ query, max_rows }, { sap, system }) {
18
27
  const r = await guardedQuery(sap, system, query, max_rows);
28
+ const structured = {
29
+ rows: r.values.length,
30
+ columns: r.columns,
31
+ values: r.values.slice(0, STRUCTURED_ROWS),
32
+ truncated: r.values.length > STRUCTURED_ROWS,
33
+ notes: r.notes,
34
+ };
19
35
  if (!r.values.length)
20
- return `La consulta se ejecutó y no devolvió filas.`;
36
+ return { text: `La consulta se ejecutó y no devolvió filas.`, structured };
21
37
  const table = tsv(r.columns, r.values.map((v) => r.columns.map((c) => v[c])));
22
- return `${r.values.length} filas\n\n${table}${r.notes.length ? `\n\n${r.notes.join("\n")}` : ""}`;
38
+ return { text: `${r.values.length} filas\n\n${table}${r.notes.length ? `\n\n${r.notes.join("\n")}` : ""}`, structured };
23
39
  },
24
40
  });
25
41
  //# sourceMappingURL=sql_query.js.map
@@ -16,6 +16,13 @@ export default defineTool({
16
16
  source: z.string().optional().describe("Fuente completa a comprobar (sin guardar)"),
17
17
  main_program: z.string().optional().describe("URI del programa principal, solo para includes ambiguos"),
18
18
  },
19
+ output: {
20
+ object: z.string(),
21
+ checked: z.enum(["proposed", "saved"]).describe("proposed = la fuente pasada sin guardar; saved = lo último guardado"),
22
+ errors: z.number().int(),
23
+ warnings: z.number().int(),
24
+ messages: z.array(z.object({ severity: z.string(), line: z.number(), offset: z.number(), text: z.string(), uri: z.string() })),
25
+ },
19
26
  async run({ object_name, object_type, include, source, main_program }, { sap }) {
20
27
  const c = await sap.adt();
21
28
  const obj = await resolveObject(c, object_name, object_type);
@@ -23,12 +30,20 @@ export default defineTool({
23
30
  const content = source ?? (await c.getObjectSource(url, { version: "inactive" }));
24
31
  const msgs = await syntaxCheck(c, obj, url, content, main_program);
25
32
  const what = source ? "fuente propuesta (no guardada)" : "última versión guardada";
26
- if (!msgs.length)
27
- return `${obj.name}: sin errores ni avisos de sintaxis (${what}, comprobado por SAP).`;
28
33
  const errs = msgs.filter(isError).length;
34
+ const structured = {
35
+ object: obj.name,
36
+ checked: source ? "proposed" : "saved",
37
+ errors: errs,
38
+ warnings: msgs.length - errs,
39
+ messages: msgs.map((m) => ({ severity: m.severity ?? "", line: m.line, offset: m.offset, text: m.text, uri: m.uri })),
40
+ };
41
+ if (!msgs.length)
42
+ return { text: `${obj.name}: sin errores ni avisos de sintaxis (${what}, comprobado por SAP).`, structured };
29
43
  return {
30
44
  text: `${obj.name}: ${errs} errores, ${msgs.length - errs} avisos (${what}).\n\n${renderSyntax(msgs)}`,
31
45
  isError: errs > 0,
46
+ structured,
32
47
  };
33
48
  },
34
49
  });
@@ -22,6 +22,7 @@ async function pool(items, n, fn) {
22
22
  }
23
23
  export default defineTool({
24
24
  name: "transport_diff",
25
+ timeoutMs: 180_000,
25
26
  title: "Qué cambió una orden (diff de código)",
26
27
  description: "Revisión de código de una orden: por cada objeto con fuente (programas, includes, clases, interfaces, FM, CDS) " +
27
28
  "compara la versión grabada con esa orden (o sus tareas) contra la versión anterior, y muestra el diff unificado. " +
@@ -36,7 +37,8 @@ export default defineTool({
36
37
  max_objects: z.number().int().min(1).max(60).default(20),
37
38
  max_diff_lines: z.number().int().min(20).max(3000).default(300).describe("Tope de líneas de diff por objeto"),
38
39
  },
39
- async run({ transport, objects, context, summary_only, max_objects, max_diff_lines }, { sap }) {
40
+ async run({ transport, objects, context, summary_only, max_objects, max_diff_lines }, ctx) {
41
+ const { sap } = ctx;
40
42
  const tr = assertTrkorr(transport);
41
43
  const heads = await orderHeaders(sap, [tr]);
42
44
  const head = heads.get(tr);
@@ -51,7 +53,11 @@ export default defineTool({
51
53
  const selected = source.filter((s) => !wanted || wanted.includes(s.name.toUpperCase()));
52
54
  const shown = selected.slice(0, max_objects);
53
55
  const c = await sap.adt();
56
+ let done = 0;
57
+ ctx.progress?.(`Comparando ${shown.length} objetos de ${root}…`, 0, shown.length);
54
58
  const blocks = await pool(shown, 4, async (ref) => {
59
+ // Cancelación entre objetos: el que está en curso termina, el siguiente ya no empieza.
60
+ ctx.signal?.throwIfAborted();
55
61
  try {
56
62
  const res = await resolveRef(c, ref);
57
63
  if (!res)
@@ -91,6 +97,9 @@ export default defineTool({
91
97
  throw te;
92
98
  return { name: ref.name, text: `■ ${ref.name}: no se pudo comparar (${te.kind}: ${te.message})`, added: 0, removed: 0 };
93
99
  }
100
+ finally {
101
+ ctx.progress?.(`${++done} de ${shown.length} objetos comparados`, done, shown.length);
102
+ }
94
103
  });
95
104
  const tot = blocks.reduce((a, b) => ({ added: a.added + b.added, removed: a.removed + b.removed }), { added: 0, removed: 0 });
96
105
  const out = [
@@ -4,6 +4,7 @@ import { tsv } from "../../core/output.js";
4
4
  import { defineTool } from "../../core/tool.js";
5
5
  export default defineTool({
6
6
  name: "where_used",
7
+ timeoutMs: 120_000,
7
8
  title: "Dónde se usa",
8
9
  description: "Lista de uso (where-used) de un objeto: quién lo referencia, con paquete y responsable. Con snippets=true añade " +
9
10
  "las líneas de código de cada uso (más lento). Ojo: no ve usos dinámicos ni exits que no declaran tipos.",
@@ -15,10 +16,13 @@ export default defineTool({
15
16
  max_results: z.number().int().min(1).max(1000).default(100),
16
17
  snippets: z.boolean().default(false),
17
18
  },
18
- async run({ object_name, object_type, max_results, snippets }, { sap }) {
19
+ async run({ object_name, object_type, max_results, snippets }, ctx) {
20
+ const { sap } = ctx;
19
21
  const c = await sap.adt();
20
22
  const obj = await resolveObject(c, object_name, object_type);
23
+ ctx.progress?.(`Buscando usos de ${obj.name} (en objetos muy usados tarda hasta un minuto)…`);
21
24
  const refs = (await c.usageReferences(obj.uri)).filter((r) => r.isResult);
25
+ ctx.signal?.throwIfAborted();
22
26
  if (!refs.length)
23
27
  return `${obj.name} (${obj.type}): el where-used se ejecutó y no encontró usos estáticos.`;
24
28
  const shown = refs.slice(0, max_results);
@@ -27,6 +31,7 @@ export default defineTool({
27
31
  "\n\n" +
28
32
  tsv(["nombre", "tipo", "paquete", "responsable", "uso"], shown.map((r) => [r["adtcore:name"], r["adtcore:type"], r.packageRef?.["adtcore:name"], r["adtcore:responsible"], r.usageInformation]));
29
33
  if (snippets) {
34
+ ctx.progress?.(`${refs.length} usos; leyendo fragmentos de los primeros ${Math.min(shown.length, 30)}…`);
30
35
  const sn = await c.usageReferenceSnippets(shown.slice(0, 30));
31
36
  const lines = sn.flatMap((s) => s.snippets.map((x) => `${s.objectIdentifier} L${x.uri?.start?.line ?? "?"}: ${x.content.trim()}`));
32
37
  out += `\n\nFragmentos (hasta 30 objetos):\n${lines.join("\n")}`;
@@ -30,6 +30,10 @@ export default defineTool({
30
30
  if (!check)
31
31
  continue;
32
32
  const sap = pool.get(s);
33
+ if (sap.circuitOpenFor()) {
34
+ out.push(" circuito abierto por fallos de red seguidos: se cierra y se reintenta ahora");
35
+ sap.resetCircuit();
36
+ }
33
37
  try {
34
38
  const caps = await sap.capabilities(refresh_discovery);
35
39
  const rel = await sap.release();
package/docs/TOOLS.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Referencia de tools — abap-adt-doZimple
2
2
 
3
- Generado desde el código con `npm run docs`. 47 tools en 9 grupos y 3 flujos guiados.
3
+ Generado desde el código con `npm run docs`. 48 tools en 9 grupos y 3 flujos guiados.
4
4
  Producto de [DoZimple](https://dozimple.cl).
5
5
 
6
6
  ## Índice
@@ -11,7 +11,7 @@ Producto de [DoZimple](https://dozimple.cl).
11
11
  - [Consulta de datos](#datos) — 2 tools: `sql_query`, `table_contents`
12
12
  - [Diagnóstico de incidentes](#diagnostico) — 4 tools: `dumps`, `jobs`, `application_log`, `gateway_errors`
13
13
  - [Documentación SAP](#documentacion) — 7 tools: `abap_feature_matrix`, `docs_search`, `docs_fetch`, `clean_core_objects`, `clean_core_object`, `abap_lint`, `docs_community_search`
14
- - [Escritura controlada](#escritura) — 4 tools: `write_source`, `activate`, `write_text_elements`, `create_transport`
14
+ - [Escritura controlada](#escritura) — 5 tools: `write_source`, `revert_source`, `activate`, `write_text_elements`, `create_transport`
15
15
  - [DoZimple Transport Risk](#transport-risk) — 7 tools: `analyze_transport_risk`, `import_health`, `failure_ranking`, `change_audit`, `object_transport_history`, `remote_source`, `transport_source_check`
16
16
  - [Operación y crecimiento](#operacion) — 4 tools: `sap_systems`, `report_gap`, `close_gap`, `usage_stats`
17
17
  - [Flujos guiados](#flujos-guiados)
@@ -206,6 +206,16 @@ Chequeo de sintaxis real de SAP (no abaplint). Si pasas `source`, se comprueba E
206
206
 
207
207
  \* obligatorio
208
208
 
209
+ Salida estructurada (`structuredContent`, además del texto):
210
+
211
+ | Campo | Tipo | Descripción |
212
+ |---|---|---|
213
+ | `object` | string | |
214
+ | `checked` | `proposed` \| `saved` | proposed = la fuente pasada sin guardar; saved = lo último guardado |
215
+ | `errors` | number | |
216
+ | `warnings` | number | |
217
+ | `messages` | lista de { severity, line, offset, text, uri } | |
218
+
209
219
  ### `run_unit_tests` — Ejecutar ABAP Unit
210
220
 
211
221
  Ejecuta los tests ABAP Unit de una clase o programa y devuelve el resultado por método, con el detalle de cada fallo. Solo corre tests RISK LEVEL HARMLESS y DURATION SHORT. Si no hay clases de test lo dice: cero tests no es un éxito.
@@ -417,6 +427,16 @@ Ejecuta un SELECT de ABAP SQL (con WHERE, JOIN, ORDER BY, subconsultas) vía la
417
427
 
418
428
  \* obligatorio
419
429
 
430
+ Salida estructurada (`structuredContent`, además del texto):
431
+
432
+ | Campo | Tipo | Descripción |
433
+ |---|---|---|
434
+ | `rows` | number | Filas devueltas |
435
+ | `columns` | lista de string | |
436
+ | `values` | lista de record | Filas como objetos columna→valor (hasta 500; el texto las trae todas) |
437
+ | `truncated` | boolean | true si values no incluye todas las filas |
438
+ | `notes` | lista de string | Avisos de la política de datos (enmascarado, tope de filas) |
439
+
420
440
  ### `table_contents` — Contenido de una tabla
421
441
 
422
442
  Filas de una tabla, vista o CDS, con columnas y filtro opcionales. Atajo de sql_query para el caso típico «enséñame lo que hay en ZTABLA donde …». Para JOIN, subconsultas o agregados usa sql_query.
@@ -684,6 +704,28 @@ Sustituye la fuente COMPLETA de un objeto existente (o de un include de clase),
684
704
 
685
705
  \* obligatorio
686
706
 
707
+ ### `revert_source` — Volver a una versión anterior
708
+
709
+ Deshace un cambio escribiendo de nuevo una versión anterior del objeto: la última activa (para limpiar un borrador inactivo tras un write_source cuya activación falló), la anterior a la activa, o una concreta del historial de object_versions. Pasa por la misma vista previa, confirmación, bloqueo y orden que write_source: nunca revierte por su cuenta. Solo objetos de código fuente.
710
+
711
+ | | |
712
+ |---|---|
713
+ | **Acceso** | Escribe (solo DEV con `allowWrite`; nunca QAS/PRD) |
714
+ | **Créditos** | [abap-adt-api](https://github.com/marcellourbani/abap-adt-api) — Marcello Urbani (MIT, dependencia) |
715
+
716
+ | Parámetro | Tipo | Por defecto | Descripción |
717
+ |---|---|---|---|
718
+ | `system` | string | | Sistema SAP configurado. Obligatorio si hay varios y ninguno por defecto. |
719
+ | `object_name` * | string | | |
720
+ | `object_type` | string | | Tipo corto: PROG, INCL, CLAS, INTF, FUGR, FUNC, DDLS, DDLX, DCLS, TABL, STRU, VIEW, DTEL, DOMA, TTYP, MSAG, XSLT, BDEF, SRVD, SRVB, ENHO. También vale el tipo ADT (p. ej. PROG/P). |
721
+ | `include` | `main` \| `definitions` \| `implementations` \| `macros` \| `testclasses` | "main" | |
722
+ | `target` | union | "active" | «active»: la última versión activa (deshace un borrador inactivo, p. ej. un write_source cuya activación falló). «previous»: la versión anterior a la activa (deshace la última activación). Un número N: la versión N tal como la lista object_versions. |
723
+ | `transport` | string | | Orden (o tarea) donde debe ir la reversión. Obligatoria salvo objetos locales |
724
+ | `activate` | boolean | true | |
725
+ | `confirm_token` | string | | Token de la vista previa. Sin él la tool no escribe: devuelve qué va a cambiar y el token, que se usa tras la conformidad del usuario (un solo uso, 10 min). |
726
+
727
+ \* obligatorio
728
+
687
729
  ### `activate` — Activar objeto
688
730
 
689
731
  Activa un objeto y devuelve los mensajes de SAP tal cual (errores con línea, avisos, objetos que quedan inactivos).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dozimple/abap-adt",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "DoZimple MCP server for SAP ABAP: multi-system, secure by design — code review, ATC, diagnosis and documentation for ECC and S/4HANA",
5
5
  "keywords": [
6
6
  "sap",