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.
- package/adt-manage-systems-handoff.md +278 -0
- package/config-cli.mjs +3 -63
- package/config-manager.mjs +127 -0
- package/package.json +1 -1
- package/sso-sap-client.mjs +344 -10
|
@@ -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
|
-
|
|
155
|
-
|
|
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.`);
|
package/config-manager.mjs
CHANGED
|
@@ -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
package/sso-sap-client.mjs
CHANGED
|
@@ -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
|
-
|
|
390
|
-
|
|
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() {
|
|
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
|
-
|
|
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(); },
|
|
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
|
}
|