abap-local-client 1.4.14 → 1.4.17

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.
@@ -0,0 +1,278 @@
1
+ # Handoff — implementar `add_system` / `list_systems` / `remove_system` / `update_system` no `abap-local-client`
2
+
3
+ > Este documento é para o agente/dev que vai alterar o repositório **`abap-local-client`**
4
+ > (pacote npm separado, não faz parte do repo `abap-mcp`). O lado do servidor MCP (`abap-mcp`)
5
+ > já está implementado e pronto — falta só o cliente local saber responder a estes comandos.
6
+ >
7
+ > **`add_system`**: spec confirmado diretamente pelo agente do `abap-local-client` — os 5 modos
8
+ > de autenticação e seus parâmetros abaixo refletem exatamente o CLI `add-system` atual.
9
+ >
10
+ > **`list_systems` / `remove_system` / `update_system`**: comandos **novos, propostos pelo lado
11
+ > do servidor** — ainda não confirmados/implementados no `abap-local-client`. Precisam de spec
12
+ > equivalente (ex.: `list-systems`, `remove-system`, `update-system` no CLI) antes de ativar essas
13
+ > operações em produção. Até lá, chamadas com essas operações vão falhar como qualquer comando
14
+ > desconhecido (ver seção "Comando desconhecido" abaixo).
15
+
16
+ ## Contexto
17
+
18
+ Hoje, para adicionar um ambiente SAP, o usuário roda manualmente algo como:
19
+
20
+ ```bash
21
+ npx abap-local-client@1.4.14 add-system PIRA_S4D snc --host vhlbvs4dci.sap.piracanjuba.com.br --sysnr 00 --client 100 --snc-partner-name "p:CN=svc.ssoS4D" --language PT
22
+ ```
23
+
24
+ O objetivo é permitir que o **agente de IA no Cursor/chat** faça isso por ele — adicionar, listar,
25
+ remover e atualizar ambientes — sem o usuário precisar abrir terminal. Do lado do servidor, todas
26
+ essas operações estão unificadas em uma única tool MCP: `adt_manage_systems({ operation: "add" | "list" | "remove" | "update", ... })`.
27
+ A tool já manda o comando certo pelo WebSocket — só falta o cliente local **escutar e tratar** cada
28
+ `action`.
29
+
30
+ O padrão de protocolo é **exatamente o mesmo** já usado por `switch_system` e `refresh_systems`
31
+ (que o cliente já implementa hoje) — são só novos `action`/`command` a mais. Cada operação da tool
32
+ MCP vira um `action` diferente no envelope WebSocket (`add_system`, `list_systems`,
33
+ `remove_system`, `update_system`) — não é um único action compartilhado.
34
+
35
+ ## Operação `add` — os 5 modos de autenticação (spec confirmado)
36
+
37
+ | Modo | Obrigatórios | Opcionais |
38
+ |---|---|---|
39
+ | `basic` | `--host`, `--port`, `--client`, `--username`, `--password` | `--saprouter`, `--language` |
40
+ | `spnego` | `--host`, `--port`, `--client` | `--service-principal`, `--language` |
41
+ | `x509` | `--host`, `--port`, `--client`, `--ca-cert`, `--ca-key`, `--login-id` | `--language` |
42
+ | `saml` | `--base-url`, `--client` | `--headless`, `--timeout-ms`, `--language` |
43
+ | `snc` (usado no Piracanjuba/Vale) | `--host`, `--sysnr`, `--client`, `--snc-partner-name` | `--snc-my-name`, `--snc-qop`, `--snc-lib`, `--username`, `--saprouter`, `--language` |
44
+
45
+ **Pontos importantes:**
46
+ - Só `basic` e `snc` aceitam `--saprouter`. Nos outros 3 modos (`spnego`, `x509`, `saml`) esse flag
47
+ não existe.
48
+ - `basic`/`spnego`/`x509` usam `--port` (porta HTTP/HTTPS); só `snc` usa `--sysnr` (número do
49
+ sistema SAP). Não são a mesma coisa — a tool MCP manda os dois como campos separados
50
+ (`port` vs `sysnr`) e nunca os dois juntos no mesmo comando.
51
+ - `saml` é o único modo que não usa `--host`/`--port` — usa `--base-url` direto.
52
+
53
+ ### O que a tool MCP envia (`operation: "add"`)
54
+
55
+ Mensagem recebida pelo cliente local via WebSocket (mesmo canal/autenticação já existentes).
56
+ Exemplo real (modo `snc`, igual ao uso no Piracanjuba):
57
+
58
+ ```json
59
+ {
60
+ "action": "add_system",
61
+ "callspecs": [
62
+ {
63
+ "command": "add_system",
64
+ "systemId": "PIRA_S4D",
65
+ "connectionType": "snc",
66
+ "host": "vhlbvs4dci.sap.piracanjuba.com.br",
67
+ "sysnr": "00",
68
+ "client": "100",
69
+ "sncPartnerName": "p:CN=svc.ssoS4D",
70
+ "language": "PT"
71
+ }
72
+ ],
73
+ "requestId": "req_1730900000000_abc123xyz",
74
+ "timestamp": "2026-07-06T19:57:00.000Z"
75
+ }
76
+ ```
77
+
78
+ Ou seja: o payload relevante está em `callspecs[0]` (sempre um array de 1 elemento neste comando —
79
+ o servidor nunca manda mais de um por vez para este action). O cliente deve:
80
+ 1. Ler `message.action === "add_system"`.
81
+ 2. Pegar `message.callspecs[0]` como o comando real.
82
+ 3. Executar o equivalente ao CLI `add-system` com esses parâmetros.
83
+ 4. Responder usando o `requestId` recebido (ver formato de resposta abaixo).
84
+
85
+ ### Mapeamento completo de campos → flags do CLI
86
+
87
+ | Campo no JSON | Flag do CLI | Modo(s) |
88
+ |---|---|---|
89
+ | `systemId` | 1º posicional (`SYSTEM_ID`) | todos — já vem em UPPERCASE do servidor |
90
+ | `connectionType` | 2º posicional (`basic`\|`spnego`\|`x509`\|`saml`\|`snc`) | todos |
91
+ | `host` | `--host` | basic, spnego, x509, snc |
92
+ | `port` | `--port` | basic, spnego, x509 |
93
+ | `sysnr` | `--sysnr` | snc |
94
+ | `client` | `--client` | todos |
95
+ | `language` | `--language` | todos (opcional) |
96
+ | `username` | `--username` | basic (obrigatório), snc (opcional) |
97
+ | `password` | `--password` | basic |
98
+ | `saprouter` | `--saprouter` | basic, snc (opcional) |
99
+ | `servicePrincipal` | `--service-principal` | spnego (opcional) |
100
+ | `caCert` | `--ca-cert` | x509 |
101
+ | `caKey` | `--ca-key` | x509 |
102
+ | `loginId` | `--login-id` | x509 |
103
+ | `baseUrl` | `--base-url` | saml |
104
+ | `headless` | `--headless` | saml (opcional, boolean) |
105
+ | `timeoutMs` | `--timeout-ms` | saml (opcional, number) |
106
+ | `sncPartnerName` | `--snc-partner-name` | snc |
107
+ | `sncMyName` | `--snc-my-name` | snc (opcional) |
108
+ | `sncQop` | `--snc-qop` | snc (opcional) |
109
+ | `sncLib` | `--snc-lib` | snc (opcional) |
110
+ | `extraParams` | objeto `{ "flag-name": "valor" }` | qualquer flag futuro não mapeado — repassar como está |
111
+
112
+ ## Operação `list` — proposta
113
+
114
+ ### O que a tool MCP envia (`operation: "list"`)
115
+
116
+ ```json
117
+ {
118
+ "action": "list_systems",
119
+ "callspecs": [ { "command": "list_systems" } ],
120
+ "requestId": "req_...",
121
+ "timestamp": "..."
122
+ }
123
+ ```
124
+
125
+ Nenhum parâmetro além do `command`. O cliente deve devolver todos os sistemas atualmente
126
+ disponíveis para `switch_system` (tanto os vindos do admin portal via `refresh_systems`, quanto os
127
+ adicionados localmente via `add_system`).
128
+
129
+ ### Resposta esperada
130
+
131
+ ```json
132
+ {
133
+ "type": "response",
134
+ "requestId": "req_...",
135
+ "success": true,
136
+ "data": {
137
+ "systems": [
138
+ { "systemId": "PIRA_S4D", "connectionType": "snc", "host": "vhlbvs4dci.sap.piracanjuba.com.br", "client": "100" },
139
+ { "systemId": "S4H_100", "connectionType": "basic", "host": "...", "client": "100" }
140
+ ]
141
+ }
142
+ }
143
+ ```
144
+
145
+ **Nunca incluir `password`/`caKey`/segredos no retorno** — só metadados de conexão (host,
146
+ connectionType, client, etc.).
147
+
148
+ ## Operação `remove` — proposta
149
+
150
+ ### O que a tool MCP envia (`operation: "remove"`)
151
+
152
+ ```json
153
+ {
154
+ "action": "remove_system",
155
+ "callspecs": [ { "command": "remove_system", "systemId": "PIRA_S4D" } ],
156
+ "requestId": "req_...",
157
+ "timestamp": "..."
158
+ }
159
+ ```
160
+
161
+ Se `systemId` não existir, responder `success: false` com erro claro (não travar). Se for o
162
+ sistema ativo no momento, o cliente decide a política (ex.: bloquear remoção do sistema ativo, ou
163
+ remover e cair para "nenhum sistema selecionado") — documentar a escolha feita.
164
+
165
+ ## Operação `update` — proposta
166
+
167
+ ### O que a tool MCP envia (`operation: "update"`)
168
+
169
+ Mesmo formato de `add_system`, mas com `action: "update_system"` e apenas os campos que mudaram
170
+ (mais `systemId` e, se estiver trocando o modo de autenticação, `connectionType` + todos os campos
171
+ obrigatórios do novo modo):
172
+
173
+ ```json
174
+ {
175
+ "action": "update_system",
176
+ "callspecs": [
177
+ {
178
+ "command": "update_system",
179
+ "systemId": "PIRA_S4D",
180
+ "host": "novo-host.sap.piracanjuba.com.br"
181
+ }
182
+ ],
183
+ "requestId": "req_...",
184
+ "timestamp": "..."
185
+ }
186
+ ```
187
+
188
+ Semântica esperada: **patch parcial** — só os campos presentes são alterados; os demais mantêm o
189
+ valor já salvo. Se `systemId` não existir, responder `success: false` com erro claro.
190
+
191
+ ## O que o cliente deve responder (todas as operações)
192
+
193
+ Mesmo formato de resposta que `switch_system`/`refresh_systems` já usam hoje — uma mensagem com
194
+ `type: "response"` e o **mesmo `requestId`** recebido na requisição:
195
+
196
+ **Sucesso (`add`/`remove`/`update`):**
197
+ ```json
198
+ {
199
+ "type": "response",
200
+ "requestId": "req_1730900000000_abc123xyz",
201
+ "success": true,
202
+ "data": { "systemId": "PIRA_S4D", "message": "Sistema adicionado com sucesso" }
203
+ }
204
+ ```
205
+
206
+ **Falha (ex.: sistema já existe, host inválido, flag faltando, sistema não encontrado):**
207
+ ```json
208
+ {
209
+ "type": "response",
210
+ "requestId": "req_1730900000000_abc123xyz",
211
+ "success": false,
212
+ "error": "Sistema PIRA_S4D já existe. Use update_system ou remova-o primeiro."
213
+ }
214
+ ```
215
+
216
+ **Importante:** para `add`/`remove`/`update`, `data` deve ser um **objeto simples, não um array** —
217
+ o lado do servidor (`tool-wrapper.ts`) só faz parsing item-a-item quando `data` é array (usado
218
+ pelas ações de ADT REST callspecs); para estes comandos o servidor apenas repassa a mensagem de
219
+ sucesso fixa (`successMessage`) e ignora o conteúdo de `data` quando ele não é array — mas ainda
220
+ assim mantenha `data` como objeto (nunca `null`/`undefined` em caso de sucesso) para consistência.
221
+ Para `list`, `data.systems` deve ser um array (ver seção da operação `list` acima).
222
+
223
+ ## Regras de negócio a implementar
224
+
225
+ 1. **Idempotência clara (`add`):** se `systemId` já existir na config local, retornar
226
+ `success: false` com `error` explicando isso (não sobrescrever silenciosamente, e não
227
+ travar/crashar).
228
+ 2. **Validação por `connectionType` (`add`/`update`):** usar a tabela de obrigatórios/opcionais
229
+ acima. Se faltar campo obrigatório, responder `success: false` com `error` claro dizendo qual
230
+ campo falta — não travar o processo do cliente.
231
+ 3. **Rejeitar `saprouter` fora de `basic`/`snc`** — se vier em `spnego`/`x509`/`saml`, é um uso
232
+ indevido (a tool MCP já bloqueia isso do lado do servidor, mas validar de novo no cliente é
233
+ defesa em profundidade).
234
+ 4. **Nunca logar `password`/`caKey` em texto puro** (nem em stdout/console, nem no arquivo de
235
+ config em texto simples se possível).
236
+ 5. **Nunca ecoar `password`/`caKey` de volta** — nem no campo `data` da resposta de sucesso de
237
+ `add`/`update`, nem na listagem de `list`.
238
+ 6. **Comando desconhecido:** se o cliente ainda não tiver essa versão implementada e receber
239
+ `action` desconhecido (`add_system`, `list_systems`, `remove_system` ou `update_system`),
240
+ responder `success: false, error: "Comando '<action>' não suportado nesta versão do cliente. Atualize com: npx abap-local-client@latest"`
241
+ em vez de travar/ignorar silenciosamente — dá um erro legível no chat em vez de timeout
242
+ genérico.
243
+ 7. **`remove`/`update` de sistema inexistente:** responder `success: false` com erro claro
244
+ ("Sistema X não encontrado"), nunca criar um novo registro como efeito colateral.
245
+
246
+ ## Como testar de ponta a ponta
247
+
248
+ 1. Atualizar o `abap-local-client` local para a versão com os handlers novos, já conectado ao MCP
249
+ server (`abap-mcp`) com uma API key válida.
250
+ 2. No chat/Cursor, chamar a tool MCP (exemplo `add`, modo `snc`):
251
+ ```json
252
+ {
253
+ "operation": "add",
254
+ "system_id": "PIRA_S4D",
255
+ "connection_type": "snc",
256
+ "host": "vhlbvs4dci.sap.piracanjuba.com.br",
257
+ "sysnr": "00",
258
+ "client": "100",
259
+ "snc_partner_name": "p:CN=svc.ssoS4D",
260
+ "language": "PT"
261
+ }
262
+ ```
263
+ 3. Confirmar que o sistema aparece disponível para `adt_switch_system({ system: "PIRA_S4D" })`.
264
+ 4. Chamar `{ "operation": "list" }` e confirmar que `PIRA_S4D` aparece na lista.
265
+ 5. Repetir a chamada de `add` — deve retornar erro claro de "já existe", não duplicar nem quebrar.
266
+ 6. Chamar `{ "operation": "update", "system_id": "PIRA_S4D", "language": "EN" }` e confirmar via
267
+ `list` que só `language` mudou.
268
+ 7. Chamar `{ "operation": "remove", "system_id": "PIRA_S4D" }` e confirmar via `list` que sumiu.
269
+ 8. Repetir o teste de `add` para os outros 4 modos (`basic`, `spnego`, `x509`, `saml`) com dados de
270
+ exemplo.
271
+
272
+ ## Referência — arquivos do lado do servidor (abap-mcp, já implementado)
273
+
274
+ - `src/tools/system/manage-systems.ts` — definição e handler da tool MCP `adt_manage_systems`
275
+ (operations: add, list, remove, update)
276
+ - `src/websocket/tool-wrapper.ts` — `executeWithWebSocketFallback()`, monta o envelope `{ action, callspecs }`
277
+ - `src/websocket/connection-manager.ts` — `sendRequest()` adiciona `requestId`/`timestamp` e espera resposta com `type: "response"`
278
+ - `src/tools/system/switch-system.ts` / `refresh-systems.ts` — exemplos já funcionando do mesmo padrão de comando (ficam como tools separadas, não fazem parte de `adt_manage_systems`)
package/config-cli.mjs CHANGED
@@ -151,73 +151,13 @@ switch (command) {
151
151
  process.exit(1);
152
152
  }
153
153
 
154
- const validModes = ['basic', 'spnego', 'x509', 'saml', 'snc'];
155
- if (!validModes.includes(authMode)) {
156
- console.error(`Invalid auth mode: ${authMode}. Use: ${validModes.join(', ')}`);
154
+ if (!config.VALID_AUTH_MODES.includes(authMode)) {
155
+ console.error(`Invalid auth mode: ${authMode}. Use: ${config.VALID_AUTH_MODES.join(', ')}`);
157
156
  process.exit(1);
158
157
  }
159
158
 
160
159
  const opts = parseKeyValue(args.slice(3));
161
-
162
- // Build connection object based on auth mode
163
- let connection = {};
164
- switch (authMode) {
165
- case 'basic':
166
- connection = {
167
- host: opts.host || 'localhost',
168
- port: opts.port || '44301',
169
- client: opts.client || '100',
170
- username: opts.username || '',
171
- password: opts.password || '',
172
- saprouter: opts.saprouter || '',
173
- language: opts.language || 'EN',
174
- };
175
- break;
176
- case 'spnego':
177
- connection = {
178
- host: opts.host || '',
179
- port: opts.port || '44301',
180
- client: opts.client || '100',
181
- servicePrincipal: opts.service_principal || '',
182
- language: opts.language || 'EN',
183
- };
184
- break;
185
- case 'x509':
186
- connection = {
187
- host: opts.host || '',
188
- port: opts.port || '44301',
189
- client: opts.client || '100',
190
- caCertPem: opts.ca_cert || '',
191
- caKeyPem: opts.ca_key || '',
192
- loginIdentifier: opts.login_id || '',
193
- validityMinutes: parseInt(opts.validity_minutes || '5', 10),
194
- language: opts.language || 'EN',
195
- };
196
- break;
197
- case 'saml':
198
- connection = {
199
- baseUrl: opts.base_url || '',
200
- client: opts.client || '100',
201
- headless: opts.headless === 'true',
202
- loginTimeoutMs: parseInt(opts.timeout_ms || '600000', 10),
203
- language: opts.language || 'EN',
204
- };
205
- break;
206
- case 'snc':
207
- connection = {
208
- host: opts.host || '',
209
- sysnr: opts.sysnr || '00',
210
- client: opts.client || '100',
211
- username: opts.username || '',
212
- saprouter: opts.saprouter || '',
213
- sncPartnerName: opts.snc_partner_name || '',
214
- sncMyName: opts.snc_my_name || '',
215
- sncQop: opts.snc_qop || '3',
216
- sncLib: opts.snc_lib || '',
217
- language: opts.language || 'EN',
218
- };
219
- break;
220
- }
160
+ const connection = config.buildConnection(authMode, opts);
221
161
 
222
162
  config.addOrUpdateSystem(systemId, { authMode, connection, enabled: true });
223
163
  console.log(`✅ System "${systemId}" (${authMode}) saved.`);
@@ -207,6 +207,133 @@ export function addOrUpdateSystem(systemId, systemConfig) {
207
207
  saveConfig(config);
208
208
  }
209
209
 
210
+ /**
211
+ * Auth-mode metadata: required/optional connection fields.
212
+ * Keys match the internal `opts` naming used by buildConnection() (snake_case,
213
+ * mirroring the CLI's --flag-name → flag_name convention).
214
+ */
215
+ export const AUTH_MODE_FIELDS = {
216
+ basic: { required: ['host', 'port', 'client', 'username', 'password'], optional: ['saprouter', 'language'] },
217
+ spnego: { required: ['host', 'port', 'client'], optional: ['service_principal', 'language'] },
218
+ x509: { required: ['host', 'port', 'client', 'ca_cert', 'ca_key', 'login_id'], optional: ['language'] },
219
+ saml: { required: ['base_url', 'client'], optional: ['headless', 'timeout_ms', 'language'] },
220
+ snc: { required: ['host', 'sysnr', 'client', 'snc_partner_name'], optional: ['snc_my_name', 'snc_qop', 'snc_lib', 'username', 'saprouter', 'language'] },
221
+ };
222
+
223
+ export const VALID_AUTH_MODES = Object.keys(AUTH_MODE_FIELDS);
224
+
225
+ /**
226
+ * Build a `connection` object for a system from raw options, honoring the
227
+ * same defaults for every auth mode. Shared by config-cli.mjs (CLI) and
228
+ * sso-sap-client.mjs (WebSocket `add_system` command) so both entry points
229
+ * behave identically.
230
+ * @param {string} authMode - basic|spnego|x509|saml|snc
231
+ * @param {object} opts - snake_case keys (host, port, sysnr, client, username,
232
+ * password, saprouter, language, service_principal, ca_cert, ca_key,
233
+ * login_id, validity_minutes, base_url, headless, timeout_ms,
234
+ * snc_partner_name, snc_my_name, snc_qop, snc_lib)
235
+ * @returns {object} connection
236
+ */
237
+ export function buildConnection(authMode, opts = {}) {
238
+ const isTrue = (v) => v === true || v === 'true';
239
+ switch (authMode) {
240
+ case 'basic':
241
+ return {
242
+ host: opts.host || 'localhost',
243
+ port: opts.port || '44301',
244
+ client: opts.client || '100',
245
+ username: opts.username || '',
246
+ password: opts.password || '',
247
+ saprouter: opts.saprouter || '',
248
+ language: opts.language || 'EN',
249
+ };
250
+ case 'spnego':
251
+ return {
252
+ host: opts.host || '',
253
+ port: opts.port || '44301',
254
+ client: opts.client || '100',
255
+ servicePrincipal: opts.service_principal || '',
256
+ language: opts.language || 'EN',
257
+ };
258
+ case 'x509':
259
+ return {
260
+ host: opts.host || '',
261
+ port: opts.port || '44301',
262
+ client: opts.client || '100',
263
+ caCertPem: opts.ca_cert || '',
264
+ caKeyPem: opts.ca_key || '',
265
+ loginIdentifier: opts.login_id || '',
266
+ validityMinutes: parseInt(opts.validity_minutes || '5', 10),
267
+ language: opts.language || 'EN',
268
+ };
269
+ case 'saml':
270
+ return {
271
+ baseUrl: opts.base_url || '',
272
+ client: opts.client || '100',
273
+ headless: isTrue(opts.headless),
274
+ loginTimeoutMs: parseInt(opts.timeout_ms || '600000', 10),
275
+ language: opts.language || 'EN',
276
+ };
277
+ case 'snc':
278
+ return {
279
+ host: opts.host || '',
280
+ sysnr: opts.sysnr || '00',
281
+ client: opts.client || '100',
282
+ username: opts.username || '',
283
+ saprouter: opts.saprouter || '',
284
+ sncPartnerName: opts.snc_partner_name || '',
285
+ sncMyName: opts.snc_my_name || '',
286
+ sncQop: opts.snc_qop || '3',
287
+ sncLib: opts.snc_lib || '',
288
+ language: opts.language || 'EN',
289
+ };
290
+ default:
291
+ return {};
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Validate that all required fields for an auth mode are present (non-empty)
297
+ * in opts. Returns an array of missing field names (snake_case), empty if OK.
298
+ */
299
+ export function getMissingRequiredFields(authMode, opts = {}) {
300
+ const spec = AUTH_MODE_FIELDS[authMode];
301
+ if (!spec) return [`unknown authMode: ${authMode}`];
302
+ return spec.required.filter((f) => {
303
+ const v = opts[f];
304
+ return v === undefined || v === null || v === '';
305
+ });
306
+ }
307
+
308
+ // Fields that must never be exposed outside the encrypted local store
309
+ // (e.g. in list_systems responses sent back to the MCP server).
310
+ const SENSITIVE_CONNECTION_FIELDS = ['password', 'caKeyPem'];
311
+
312
+ /**
313
+ * Return a copy of a connection object with secrets stripped out.
314
+ * @param {object} connection
315
+ */
316
+ export function sanitizeConnection(connection = {}) {
317
+ const clean = { ...connection };
318
+ for (const f of SENSITIVE_CONNECTION_FIELDS) delete clean[f];
319
+ return clean;
320
+ }
321
+
322
+ /**
323
+ * List all systems with sanitized connection metadata (no secrets).
324
+ * Used by the WebSocket `list_systems` command.
325
+ * @returns {object[]} [{ systemId, connectionType, enabled, host, client, ... }]
326
+ */
327
+ export function listSystemsDetailed() {
328
+ const cfg = loadConfig();
329
+ return Object.entries(cfg.systems).map(([id, sys]) => ({
330
+ systemId: id,
331
+ connectionType: sys.authMode || 'basic',
332
+ enabled: sys.enabled !== false,
333
+ ...sanitizeConnection(sys.connection || {}),
334
+ }));
335
+ }
336
+
210
337
  /**
211
338
  * Remove a system by ID.
212
339
  * @param {string} systemId
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "abap-local-client",
3
- "version": "1.4.14",
3
+ "version": "1.4.17",
4
4
  "description": "SAP Local Client — WebSocket bridge with Basic, SPNEGO, X.509, SAML & SNC authentication",
5
5
  "main": "sso-sap-client.mjs",
6
6
  "type": "module",
@@ -21,6 +21,196 @@ function resolveUsername(conn) {
21
21
  return (conn.username || '').toUpperCase();
22
22
  }
23
23
 
24
+ // ════════════════════════════════════════════════════════════════════════════
25
+ // add_system — handles the MCP `add_system` WebSocket command
26
+ // ════════════════════════════════════════════════════════════════════════════
27
+
28
+ // Maps the camelCase JSON fields sent by the MCP server to the snake_case
29
+ // `opts` keys expected by config.buildConnection()/getMissingRequiredFields().
30
+ const ADD_SYSTEM_FIELD_MAP = {
31
+ host: 'host',
32
+ port: 'port',
33
+ sysnr: 'sysnr',
34
+ client: 'client',
35
+ language: 'language',
36
+ username: 'username',
37
+ password: 'password',
38
+ saprouter: 'saprouter',
39
+ servicePrincipal: 'service_principal',
40
+ caCert: 'ca_cert',
41
+ caKey: 'ca_key',
42
+ loginId: 'login_id',
43
+ baseUrl: 'base_url',
44
+ headless: 'headless',
45
+ timeoutMs: 'timeout_ms',
46
+ sncPartnerName: 'snc_partner_name',
47
+ sncMyName: 'snc_my_name',
48
+ sncQop: 'snc_qop',
49
+ sncLib: 'snc_lib',
50
+ };
51
+ const OPTS_TO_FIELD_MAP = Object.fromEntries(Object.entries(ADD_SYSTEM_FIELD_MAP).map(([k, v]) => [v, k]));
52
+
53
+ function kebabToCamel(str) {
54
+ return str.replace(/[-_]([a-z0-9])/gi, (_, c) => c.toUpperCase());
55
+ }
56
+
57
+ /**
58
+ * Handle the `add_system` command: validate the payload and persist a new
59
+ * SAP system into the encrypted local config, using the same connection
60
+ * builder as the `add-system` CLI command.
61
+ * @param {object} spec - callspecs[0] payload from the MCP server
62
+ * @returns {{ success: boolean, data?: object, error?: string }}
63
+ */
64
+ function handleAddSystem(spec = {}) {
65
+ const systemId = String(spec.systemId || '').trim().toUpperCase();
66
+ const authMode = String(spec.connectionType || spec.authMode || '').trim().toLowerCase();
67
+
68
+ if (!systemId) return { success: false, error: 'systemId é obrigatório.' };
69
+ if (!config.VALID_AUTH_MODES.includes(authMode)) {
70
+ return { success: false, error: `connectionType inválido: "${spec.connectionType || ''}". Use um de: ${config.VALID_AUTH_MODES.join(', ')}.` };
71
+ }
72
+ if (config.getSystem(systemId)) {
73
+ return { success: false, error: `Sistema "${systemId}" já existe. Remova-o primeiro (remove-system) ou use outro systemId.` };
74
+ }
75
+ if (spec.saprouter && !['basic', 'snc'].includes(authMode)) {
76
+ return { success: false, error: `"saprouter" não é suportado no modo "${authMode}" (apenas basic/snc).` };
77
+ }
78
+
79
+ const opts = {};
80
+ for (const [jsonKey, optsKey] of Object.entries(ADD_SYSTEM_FIELD_MAP)) {
81
+ if (spec[jsonKey] !== undefined && spec[jsonKey] !== null) opts[optsKey] = spec[jsonKey];
82
+ }
83
+
84
+ const missing = config.getMissingRequiredFields(authMode, opts);
85
+ if (missing.length > 0) {
86
+ const friendly = missing.map(f => OPTS_TO_FIELD_MAP[f] || f);
87
+ return { success: false, error: `Campo(s) obrigatório(s) faltando para o modo "${authMode}": ${friendly.join(', ')}.` };
88
+ }
89
+
90
+ const connection = config.buildConnection(authMode, opts);
91
+
92
+ // Pass through any future/unmapped flags verbatim (kebab-case → camelCase)
93
+ if (spec.extraParams && typeof spec.extraParams === 'object') {
94
+ for (const [k, v] of Object.entries(spec.extraParams)) connection[kebabToCamel(k)] = v;
95
+ }
96
+
97
+ config.addOrUpdateSystem(systemId, { authMode, connection, enabled: true });
98
+ console.log(` ✅ System "${systemId}" (${authMode}) added via add_system command`);
99
+ return { success: true, data: { systemId, message: 'Sistema adicionado com sucesso' } };
100
+ }
101
+
102
+ /**
103
+ * Handle the `list_systems` command: return all configured systems with
104
+ * sanitized connection metadata (no password/caKey).
105
+ * @returns {{ success: boolean, data: { systems: object[] } }}
106
+ */
107
+ function handleListSystems() {
108
+ return { success: true, data: { systems: config.listSystemsDetailed() } };
109
+ }
110
+
111
+ /**
112
+ * Handle the `remove_system` command.
113
+ * @param {object} spec - callspecs[0] payload from the MCP server
114
+ * @returns {{ success: boolean, data?: object, error?: string }}
115
+ */
116
+ function handleRemoveSystem(spec = {}) {
117
+ const systemId = String(spec.systemId || '').trim().toUpperCase();
118
+ if (!systemId) return { success: false, error: 'systemId é obrigatório.' };
119
+ if (!config.getSystem(systemId)) {
120
+ return { success: false, error: `Sistema "${systemId}" não encontrado.` };
121
+ }
122
+ config.removeSystem(systemId);
123
+ console.log(` 🗑️ System "${systemId}" removed via remove_system command`);
124
+ return { success: true, data: { systemId, message: 'Sistema removido com sucesso' } };
125
+ }
126
+
127
+ // Maps the camelCase JSON fields sent by the MCP server directly to the
128
+ // `connection` object's own key names (used for partial-patch merges in
129
+ // update_system — different from ADD_SYSTEM_FIELD_MAP, which maps to the
130
+ // snake_case `opts` keys consumed by buildConnection()).
131
+ const CONNECTION_FIELD_MAP = {
132
+ host: 'host',
133
+ port: 'port',
134
+ sysnr: 'sysnr',
135
+ client: 'client',
136
+ language: 'language',
137
+ username: 'username',
138
+ password: 'password',
139
+ saprouter: 'saprouter',
140
+ servicePrincipal: 'servicePrincipal',
141
+ caCert: 'caCertPem',
142
+ caKey: 'caKeyPem',
143
+ loginId: 'loginIdentifier',
144
+ baseUrl: 'baseUrl',
145
+ headless: 'headless',
146
+ sncPartnerName: 'sncPartnerName',
147
+ sncMyName: 'sncMyName',
148
+ sncQop: 'sncQop',
149
+ sncLib: 'sncLib',
150
+ };
151
+
152
+ /**
153
+ * Handle the `update_system` command.
154
+ * - If `connectionType` is absent (or unchanged), applies a partial patch:
155
+ * only the provided fields are overwritten, everything else is preserved.
156
+ * - If `connectionType` differs from the stored mode, treats the payload like
157
+ * a fresh `add_system` for the new mode (full required-field validation,
158
+ * connection rebuilt from scratch — old mode's fields don't carry over).
159
+ * @param {object} spec - callspecs[0] payload from the MCP server
160
+ * @returns {{ success: boolean, data?: object, error?: string }}
161
+ */
162
+ function handleUpdateSystem(spec = {}) {
163
+ const systemId = String(spec.systemId || '').trim().toUpperCase();
164
+ if (!systemId) return { success: false, error: 'systemId é obrigatório.' };
165
+ const existing = config.getSystem(systemId);
166
+ if (!existing) return { success: false, error: `Sistema "${systemId}" não encontrado.` };
167
+
168
+ let newMode = null;
169
+ if (spec.connectionType !== undefined && spec.connectionType !== null && String(spec.connectionType).trim() !== '') {
170
+ newMode = String(spec.connectionType).trim().toLowerCase();
171
+ if (!config.VALID_AUTH_MODES.includes(newMode)) {
172
+ return { success: false, error: `connectionType inválido: "${spec.connectionType}". Use um de: ${config.VALID_AUTH_MODES.join(', ')}.` };
173
+ }
174
+ }
175
+ const switchingMode = newMode !== null && newMode !== existing.authMode;
176
+ const effectiveMode = newMode || existing.authMode;
177
+
178
+ if (spec.saprouter && !['basic', 'snc'].includes(effectiveMode)) {
179
+ return { success: false, error: `"saprouter" não é suportado no modo "${effectiveMode}" (apenas basic/snc).` };
180
+ }
181
+
182
+ let connection;
183
+ if (switchingMode) {
184
+ const opts = {};
185
+ for (const [jsonKey, optsKey] of Object.entries(ADD_SYSTEM_FIELD_MAP)) {
186
+ if (spec[jsonKey] !== undefined && spec[jsonKey] !== null) opts[optsKey] = spec[jsonKey];
187
+ }
188
+ const missing = config.getMissingRequiredFields(effectiveMode, opts);
189
+ if (missing.length > 0) {
190
+ const friendly = missing.map(f => OPTS_TO_FIELD_MAP[f] || f);
191
+ return { success: false, error: `Campo(s) obrigatório(s) faltando para o modo "${effectiveMode}": ${friendly.join(', ')}.` };
192
+ }
193
+ connection = config.buildConnection(effectiveMode, opts);
194
+ } else {
195
+ connection = { ...(existing.connection || {}) };
196
+ for (const [jsonKey, connKey] of Object.entries(CONNECTION_FIELD_MAP)) {
197
+ if (spec[jsonKey] === undefined || spec[jsonKey] === null) continue;
198
+ connection[connKey] = jsonKey === 'headless' ? (spec[jsonKey] === true || spec[jsonKey] === 'true') : spec[jsonKey];
199
+ }
200
+ if (spec.timeoutMs !== undefined && spec.timeoutMs !== null && effectiveMode === 'saml') {
201
+ connection.loginTimeoutMs = parseInt(spec.timeoutMs, 10);
202
+ }
203
+ }
204
+
205
+ if (spec.extraParams && typeof spec.extraParams === 'object') {
206
+ for (const [k, v] of Object.entries(spec.extraParams)) connection[kebabToCamel(k)] = v;
207
+ }
208
+
209
+ config.addOrUpdateSystem(systemId, { authMode: effectiveMode, connection, enabled: existing.enabled !== false });
210
+ console.log(` ✅ System "${systemId}" updated via update_system command${switchingMode ? ` (mode: ${existing.authMode} → ${effectiveMode})` : ''}`);
211
+ return { success: true, data: { systemId, message: 'Sistema atualizado com sucesso' } };
212
+ }
213
+
24
214
  // ════════════════════════════════════════════════════════════════════════════
25
215
  // Multi-system auth session cache
26
216
  // ════════════════════════════════════════════════════════════════════════════
@@ -327,10 +517,11 @@ class LockManager {
327
517
  // ════════════════════════════════════════════════════════════════════════════
328
518
 
329
519
  class SAPClient {
330
- constructor(sessionEntry) {
520
+ constructor(sessionEntry, onSessionCleared) {
331
521
  this.session = sessionEntry;
332
522
  this.csrfToken = null;
333
523
  this.cookies = new Map();
524
+ this.onSessionCleared = onSessionCleared || null;
334
525
 
335
526
  const conn = sessionEntry.sysConfig?.connection || {};
336
527
  const baseUrl = `https://${conn.host}:${conn.port || '44301'}`;
@@ -386,8 +577,20 @@ class SAPClient {
386
577
  return response;
387
578
  },
388
579
  error => {
389
- if (error.response?.status === 401 || error.response?.status === 403) {
390
- console.error(` ⚠️ Session error (${error.response.status}) - clearing...`);
580
+ // NOTE: only 401 (true auth/session failure) wipes the session here.
581
+ // 403 is deliberately NOT treated as "session invalid" — SAP ADT also
582
+ // returns 403 for business-logic errors that have nothing to do with
583
+ // the HTTP session (e.g. "object locked by another user", missing
584
+ // authorization for a specific object, or "CSRF token required").
585
+ // Blindly clearing cookies/CSRF on every 403 used to nuke a perfectly
586
+ // valid stateful session in the middle of a lock/save/activate flow,
587
+ // orphaning any lock the client held (SAP kept the enqueue tied to the
588
+ // now-discarded session/cookie, while the client silently opened a
589
+ // brand-new anonymous session for the next call). CSRF-required 403s
590
+ // are instead handled locally with a scoped refresh+retry in
591
+ // _executeCallspecViaHTTP.
592
+ if (error.response?.status === 401) {
593
+ console.error(` ⚠️ Session error (401) - clearing...`);
391
594
  this.clearSession();
392
595
  }
393
596
  return Promise.reject(error);
@@ -487,7 +690,7 @@ class SAPClient {
487
690
  }
488
691
  }
489
692
 
490
- async _executeCallspecViaHTTP(callspec) {
693
+ async _executeCallspecViaHTTP(callspec, isCsrfRetry = false) {
491
694
  const { method, url, headers, extract_from_response } = callspec;
492
695
  let finalUrl = url;
493
696
  let finalHeaders = { ...headers };
@@ -578,6 +781,22 @@ class SAPClient {
578
781
  return { success: !hasErrors && response.status >= 200 && response.status < 300, status: response.status, statusText: response.statusText, headers: response.headers, data: { raw: response.data, ...extractedData }, ...(errorMessages.length > 0 && { error: `SAP validation failed: ${errorMessages.join('; ')}`, errorDetails: errorMessages }) };
579
782
  } catch (error) {
580
783
  const status = error.response?.status;
784
+
785
+ // ── CSRF-required retry (scoped, one-shot) ──────────────────────────
786
+ // SAP responds 403 with `x-csrf-token: Required` when the cached token
787
+ // is stale/missing — this is NOT a session-invalidating error, it just
788
+ // means we need a fresh token. Refresh it and retry this one request
789
+ // exactly once, instead of relying on the (now-removed) blanket
790
+ // session wipe that used to happen for every 403 in the interceptor.
791
+ const csrfHeader = (error.response?.headers?.['x-csrf-token'] || '').toLowerCase();
792
+ if (status === 403 && csrfHeader === 'required' && !isCsrfRetry) {
793
+ console.warn(' 🔄 CSRF token rejected (Required) — refreshing token and retrying once...');
794
+ this.csrfToken = null;
795
+ await this.fetchCsrfToken();
796
+ if (this.csrfToken) return this._executeCallspecViaHTTP(callspec, true);
797
+ console.error(' ❌ CSRF refresh failed — no token obtained, giving up on retry');
798
+ }
799
+
581
800
  console.error(` ❌ HTTP ${status || 'network error'}: ${error.message}`);
582
801
  if (error.response?.headers) {
583
802
  console.error(` ❌ RESPONSE HEADERS:`);
@@ -613,7 +832,17 @@ class SAPClient {
613
832
  catch (error) { if (error.response?.headers['x-csrf-token']) this.csrfToken = error.response.headers['x-csrf-token']; }
614
833
  }
615
834
 
616
- clearSession() { this.csrfToken = null; this.cookies.clear(); console.log(' 🧹 Session cleared'); }
835
+ clearSession() {
836
+ this.csrfToken = null;
837
+ this.cookies.clear();
838
+ console.log(' 🧹 Session cleared');
839
+ // The stateful ADT session that owned any cached locks is gone — a lock
840
+ // handle cached against it is now orphaned, so tell the caller to drop it
841
+ // too (mirrors the RFC-side stale-lock cleanup on 423).
842
+ if (this.onSessionCleared) {
843
+ try { this.onSessionCleared(); } catch { /* best-effort */ }
844
+ }
845
+ }
617
846
 
618
847
  async ping() {
619
848
  try { const r = await this.client.get('/sap/bc/adt/compatibility/graph', { timeout: 5000 }); return { success: true, status: r.status }; }
@@ -631,6 +860,8 @@ class WebSocketClient {
631
860
  this.sapClient = null;
632
861
  this.reconnectTimer = null;
633
862
  this.keepAliveTimer = null;
863
+ this.wsPingTimer = null;
864
+ this.isAlive = false;
634
865
  this.isReconnecting = false;
635
866
  this.lockManager = new LockManager();
636
867
  this.currentSystemId = null;
@@ -641,7 +872,10 @@ class WebSocketClient {
641
872
  if (!sid) { console.error('❌ No system selected'); return false; }
642
873
  try {
643
874
  const sessionEntry = await getSessionForSystem(sid);
644
- this.sapClient = new SAPClient(sessionEntry);
875
+ this.sapClient = new SAPClient(sessionEntry, () => {
876
+ this.lockManager.clearAll();
877
+ console.log(' 🗑️ Lock cache cleared (HTTP session was reset — cached locks are now orphaned)');
878
+ });
645
879
  console.log(`✅ SAPClient initialized for ${sid}`);
646
880
  return true;
647
881
  } catch (err) {
@@ -659,10 +893,12 @@ class WebSocketClient {
659
893
  console.log(`\n🔌 Connecting to MCP Cloud...`);
660
894
  console.log(` URL: ${WS_URL}`);
661
895
  this.ws = new WebSocket(WS_URL, { headers: { 'Authorization': `Bearer ${MCP_API_KEY}` } });
896
+ this.isAlive = true;
662
897
  this.ws.on('open', () => this.onOpen());
663
898
  this.ws.on('message', (data) => this.onMessage(data));
664
899
  this.ws.on('error', (error) => this.onError(error));
665
900
  this.ws.on('close', () => this.onClose());
901
+ this.ws.on('pong', () => { this.isAlive = true; });
666
902
  }
667
903
 
668
904
  onOpen() {
@@ -670,6 +906,7 @@ class WebSocketClient {
670
906
  if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
671
907
  this.isReconnecting = false;
672
908
  this.startKeepAlive();
909
+ this.startWsPing();
673
910
  }
674
911
 
675
912
  startKeepAlive() {
@@ -682,6 +919,24 @@ class WebSocketClient {
682
919
 
683
920
  stopKeepAlive() { if (this.keepAliveTimer) { clearInterval(this.keepAliveTimer); this.keepAliveTimer = null; } }
684
921
 
922
+ // WebSocket-frame-level heartbeat (independent of the SAP keepalive above).
923
+ // Tunnels/proxies (e.g. ngrok) silently drop idle connections that have no
924
+ // traffic for a while. Sending a ping every WS_PING_INTERVAL ms keeps the
925
+ // tunnel alive and — if a pong doesn't come back in time — proactively
926
+ // terminates the dead socket so reconnection kicks in immediately instead
927
+ // of waiting for the OS to notice the broken connection.
928
+ startWsPing() {
929
+ if (this.wsPingTimer) clearInterval(this.wsPingTimer);
930
+ this.wsPingTimer = setInterval(() => {
931
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
932
+ if (!this.isAlive) { console.log(' 💔 No pong received — terminating stale connection'); this.ws.terminate(); return; }
933
+ this.isAlive = false;
934
+ this.ws.ping();
935
+ }, parseInt(env.WS_PING_INTERVAL || '20000', 10));
936
+ }
937
+
938
+ stopWsPing() { if (this.wsPingTimer) { clearInterval(this.wsPingTimer); this.wsPingTimer = null; } }
939
+
685
940
  async onMessage(data) {
686
941
  let message;
687
942
  try { message = JSON.parse(data.toString()); } catch { return; }
@@ -712,6 +967,84 @@ class WebSocketClient {
712
967
  return;
713
968
  }
714
969
 
970
+ // ── add_system ─────────────────────────────────────────────────────
971
+ if (action === 'add_system') {
972
+ const spec = Array.isArray(callspecs) ? (callspecs[0] || {}) : (callspecs || {});
973
+ try {
974
+ const result = handleAddSystem(spec);
975
+ if (result.success) this.sendResponse(requestId, true, result.data);
976
+ else this.sendResponse(requestId, false, { error: result.error });
977
+ } catch (err) {
978
+ console.error(`❌ add_system error: ${err.message}`);
979
+ this.sendResponse(requestId, false, { error: err.message });
980
+ }
981
+ return;
982
+ }
983
+
984
+ // ── list_systems ───────────────────────────────────────────────────
985
+ if (action === 'list_systems') {
986
+ try {
987
+ const result = handleListSystems();
988
+ this.sendResponse(requestId, true, result.data);
989
+ } catch (err) {
990
+ console.error(`❌ list_systems error: ${err.message}`);
991
+ this.sendResponse(requestId, false, { error: err.message });
992
+ }
993
+ return;
994
+ }
995
+
996
+ // ── remove_system ──────────────────────────────────────────────────
997
+ if (action === 'remove_system') {
998
+ const spec = Array.isArray(callspecs) ? (callspecs[0] || {}) : (callspecs || {});
999
+ try {
1000
+ const result = handleRemoveSystem(spec);
1001
+ if (result.success) {
1002
+ const removedId = result.data.systemId;
1003
+ sessions.delete(removedId);
1004
+ // Policy: removing the currently active system clears the active
1005
+ // session (falls back to "no system selected") instead of leaving
1006
+ // a dangling stale sapClient pointed at a config that no longer exists.
1007
+ if (this.currentSystemId === removedId) {
1008
+ this.sapClient = null;
1009
+ this.currentSystemId = null;
1010
+ this.lockManager.clearAll();
1011
+ console.log(` ⚠️ Active system "${removedId}" was removed — session cleared, no system selected.`);
1012
+ }
1013
+ this.sendResponse(requestId, true, result.data);
1014
+ } else {
1015
+ this.sendResponse(requestId, false, { error: result.error });
1016
+ }
1017
+ } catch (err) {
1018
+ console.error(`❌ remove_system error: ${err.message}`);
1019
+ this.sendResponse(requestId, false, { error: err.message });
1020
+ }
1021
+ return;
1022
+ }
1023
+
1024
+ // ── update_system ──────────────────────────────────────────────────
1025
+ if (action === 'update_system') {
1026
+ const spec = Array.isArray(callspecs) ? (callspecs[0] || {}) : (callspecs || {});
1027
+ try {
1028
+ const result = handleUpdateSystem(spec);
1029
+ if (result.success) {
1030
+ const updatedId = result.data.systemId;
1031
+ // Invalidate any cached session so the next call re-reads the
1032
+ // fresh config instead of reusing stale connection details.
1033
+ if (this.currentSystemId === updatedId) {
1034
+ sessions.delete(updatedId);
1035
+ this.sapClient = null;
1036
+ }
1037
+ this.sendResponse(requestId, true, result.data);
1038
+ } else {
1039
+ this.sendResponse(requestId, false, { error: result.error });
1040
+ }
1041
+ } catch (err) {
1042
+ console.error(`❌ update_system error: ${err.message}`);
1043
+ this.sendResponse(requestId, false, { error: err.message });
1044
+ }
1045
+ return;
1046
+ }
1047
+
715
1048
  // ── invoke_rfc ─────────────────────────────────────────────────────
716
1049
  if (action === 'invoke_rfc') {
717
1050
  if (!this.sapClient?.rfcClient) { this.sendResponse(requestId, false, { error: 'No RFC session' }); return; }
@@ -879,19 +1212,20 @@ class WebSocketClient {
879
1212
 
880
1213
  onClose() {
881
1214
  console.log(`\n⚠️ Disconnected`);
882
- this.ws = null; this.stopKeepAlive();
1215
+ this.ws = null; this.stopKeepAlive(); this.stopWsPing();
883
1216
  if (this.sapClient) this.sapClient.clearSession();
884
1217
  this.lockManager.clearAll();
885
1218
  if (!this.isReconnecting && !this.reconnectTimer) {
886
- console.log(` Reconnecting in 5s...`);
1219
+ const delayMs = parseInt(env.WS_RECONNECT_DELAY || '2000', 10);
1220
+ console.log(` Reconnecting in ${(delayMs / 1000).toFixed(1)}s...`);
887
1221
  this.isReconnecting = true;
888
- this.reconnectTimer = setTimeout(() => { this.isReconnecting = false; this.connect(); }, 5000);
1222
+ this.reconnectTimer = setTimeout(() => { this.isReconnecting = false; this.connect(); }, delayMs);
889
1223
  }
890
1224
  }
891
1225
 
892
1226
  disconnect() {
893
1227
  if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
894
- this.isReconnecting = false; this.stopKeepAlive();
1228
+ this.isReconnecting = false; this.stopKeepAlive(); this.stopWsPing();
895
1229
  if (this.ws) { this.ws.close(); this.ws = null; }
896
1230
  }
897
1231
  }