abap-local-client 1.4.13 → 1.4.16

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.13",
3
+ "version": "1.4.16",
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
  // ════════════════════════════════════════════════════════════════════════════
@@ -712,6 +902,84 @@ class WebSocketClient {
712
902
  return;
713
903
  }
714
904
 
905
+ // ── add_system ─────────────────────────────────────────────────────
906
+ if (action === 'add_system') {
907
+ const spec = Array.isArray(callspecs) ? (callspecs[0] || {}) : (callspecs || {});
908
+ try {
909
+ const result = handleAddSystem(spec);
910
+ if (result.success) this.sendResponse(requestId, true, result.data);
911
+ else this.sendResponse(requestId, false, { error: result.error });
912
+ } catch (err) {
913
+ console.error(`❌ add_system error: ${err.message}`);
914
+ this.sendResponse(requestId, false, { error: err.message });
915
+ }
916
+ return;
917
+ }
918
+
919
+ // ── list_systems ───────────────────────────────────────────────────
920
+ if (action === 'list_systems') {
921
+ try {
922
+ const result = handleListSystems();
923
+ this.sendResponse(requestId, true, result.data);
924
+ } catch (err) {
925
+ console.error(`❌ list_systems error: ${err.message}`);
926
+ this.sendResponse(requestId, false, { error: err.message });
927
+ }
928
+ return;
929
+ }
930
+
931
+ // ── remove_system ──────────────────────────────────────────────────
932
+ if (action === 'remove_system') {
933
+ const spec = Array.isArray(callspecs) ? (callspecs[0] || {}) : (callspecs || {});
934
+ try {
935
+ const result = handleRemoveSystem(spec);
936
+ if (result.success) {
937
+ const removedId = result.data.systemId;
938
+ sessions.delete(removedId);
939
+ // Policy: removing the currently active system clears the active
940
+ // session (falls back to "no system selected") instead of leaving
941
+ // a dangling stale sapClient pointed at a config that no longer exists.
942
+ if (this.currentSystemId === removedId) {
943
+ this.sapClient = null;
944
+ this.currentSystemId = null;
945
+ this.lockManager.clearAll();
946
+ console.log(` ⚠️ Active system "${removedId}" was removed — session cleared, no system selected.`);
947
+ }
948
+ this.sendResponse(requestId, true, result.data);
949
+ } else {
950
+ this.sendResponse(requestId, false, { error: result.error });
951
+ }
952
+ } catch (err) {
953
+ console.error(`❌ remove_system error: ${err.message}`);
954
+ this.sendResponse(requestId, false, { error: err.message });
955
+ }
956
+ return;
957
+ }
958
+
959
+ // ── update_system ──────────────────────────────────────────────────
960
+ if (action === 'update_system') {
961
+ const spec = Array.isArray(callspecs) ? (callspecs[0] || {}) : (callspecs || {});
962
+ try {
963
+ const result = handleUpdateSystem(spec);
964
+ if (result.success) {
965
+ const updatedId = result.data.systemId;
966
+ // Invalidate any cached session so the next call re-reads the
967
+ // fresh config instead of reusing stale connection details.
968
+ if (this.currentSystemId === updatedId) {
969
+ sessions.delete(updatedId);
970
+ this.sapClient = null;
971
+ }
972
+ this.sendResponse(requestId, true, result.data);
973
+ } else {
974
+ this.sendResponse(requestId, false, { error: result.error });
975
+ }
976
+ } catch (err) {
977
+ console.error(`❌ update_system error: ${err.message}`);
978
+ this.sendResponse(requestId, false, { error: err.message });
979
+ }
980
+ return;
981
+ }
982
+
715
983
  // ── invoke_rfc ─────────────────────────────────────────────────────
716
984
  if (action === 'invoke_rfc') {
717
985
  if (!this.sapClient?.rfcClient) { this.sendResponse(requestId, false, { error: 'No RFC session' }); return; }
@@ -785,7 +1053,7 @@ class WebSocketClient {
785
1053
  for (const lock of allLocks) {
786
1054
  const unlockUrl = buildAdtUnlockUrl(lock.name, lock.type, lock.lockHandle);
787
1055
  if (!unlockUrl) { console.log(` ⚠️ No ADT path for type ${lock.type} — skipping`); failed++; continue; }
788
- const r = await this.sapClient.executeCallspec({ method: 'DELETE', url: unlockUrl, headers: {} });
1056
+ const r = await this.sapClient.executeCallspec({ method: 'POST', url: unlockUrl, headers: {}, body: '' });
789
1057
  if (r.success) { this.lockManager.clearLock(lock.name, lock.type); unlocked++; console.log(` ✅ Unlocked ${lock.type}:${lock.name}`); }
790
1058
  else { failed++; console.log(` ⚠️ Failed to unlock ${lock.type}:${lock.name}: ${r.error || r.status}`); }
791
1059
  }
@@ -798,7 +1066,7 @@ class WebSocketClient {
798
1066
  const handle = lockHandle || this.lockManager.getLock(objectName, objectType)?.lockHandle || '';
799
1067
  const unlockUrl = buildAdtUnlockUrl(objectName, objectType, handle);
800
1068
  if (!unlockUrl) { results.push({ success: false, status: 0, error: `Unknown object type: ${objectType}` }); allSuccessful = false; break; }
801
- const r = await this.sapClient.executeCallspec({ method: 'DELETE', url: unlockUrl, headers: {} });
1069
+ const r = await this.sapClient.executeCallspec({ method: 'POST', url: unlockUrl, headers: {}, body: '' });
802
1070
  if (r.success) this.lockManager.clearLock(objectName, objectType);
803
1071
  results.push(r);
804
1072
  if (!r.success) { allSuccessful = false; break; }