sap-ai-dev-toolkit 0.9.1 → 0.9.3
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/README.md +37 -11
- package/dist/checksums.txt +5 -5
- package/dist/vsp-darwin-arm64 +0 -0
- package/dist/vsp-darwin-x64 +0 -0
- package/dist/vsp-linux-arm64 +0 -0
- package/dist/vsp-linux-x64 +0 -0
- package/dist/vsp-win32-x64.exe +0 -0
- package/package.json +2 -1
- package/scripts/postinstall.mjs +7 -3
- package/src/harness-mcp-config.mjs +5 -2
- package/src/launcher.mjs +42 -12
- package/src/mcp-config.mjs +264 -44
- package/src/mcp-proxy.mjs +4 -1
- package/src/setup.mjs +9 -2
- package/src/terminal-ui.mjs +40 -0
package/README.md
CHANGED
|
@@ -41,6 +41,14 @@ SAP AI Dev Toolkit connects your AI coding assistant to the SAP development tool
|
|
|
41
41
|
|
|
42
42
|
## ⚡ Quick start
|
|
43
43
|
|
|
44
|
+
The default installation is a single npx command — no global install, nothing left behind except the generated MCP configuration:
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
npx --yes --ignore-scripts sap-ai-dev-toolkit --setup
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Prefer a persistent global install instead?
|
|
51
|
+
|
|
44
52
|
```sh
|
|
45
53
|
npm install --global sap-ai-dev-toolkit
|
|
46
54
|
sap-ai-dev --setup
|
|
@@ -242,17 +250,33 @@ Once the destination server is enabled in your chat's tools picker, ask for outc
|
|
|
242
250
|
|
|
243
251
|
## 📦 Installation
|
|
244
252
|
|
|
245
|
-
|
|
253
|
+
The default installation is a single npx command that runs the guided setup directly through npm — no global install required. It works in SAP Business Application Studio and on local workstations alike:
|
|
254
|
+
|
|
255
|
+
```sh
|
|
256
|
+
npx --yes --ignore-scripts sap-ai-dev-toolkit --setup
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Prefer a persistent global install? Install globally from SAP Business Application Studio or from a local workstation with SAP GUI installed, then run setup explicitly for your environment:
|
|
246
260
|
|
|
247
261
|
| Environment / device | What to run | What setup does |
|
|
248
262
|
| --- | --- | --- |
|
|
249
|
-
| **
|
|
250
|
-
| **
|
|
251
|
-
| **
|
|
263
|
+
| **Any environment (default — no global install)** | `npx --yes --ignore-scripts sap-ai-dev-toolkit --setup` | Runs the guided setup through npm and writes generated entries that launch the pinned package through `npx` (`cmd /c npx` on Windows). Add `--npx` to force the npx launcher even when a global `sap-ai-dev` command exists. |
|
|
264
|
+
| **SAP Business Application Studio dev space (global install)** | `npm install --global sap-ai-dev-toolkit`<br>`sap-ai-dev --setup` | Discovers BAS destinations, optionally imports Cloud Foundry Destination service records, and writes VS Code/BAS MCP entries. |
|
|
265
|
+
| **Windows workstation with SAP GUI (global install)** | `npm install --global sap-ai-dev-toolkit`<br>`sap-ai-dev --setup` | Discovers SAP GUI landscape entries, asks for SAP client(s) and ADT URL, then writes local VS Code MCP entries. Windows SSO can be selected if the ADT endpoint supports Negotiate/SPNEGO. |
|
|
266
|
+
| **macOS workstation with SAP GUI (global install)** | `npm install --global sap-ai-dev-toolkit`<br>`sap-ai-dev --setup` | Discovers SAP GUI landscape entries, asks for SAP client(s) and ADT URL, then writes local VS Code MCP entries with secure login prompts. |
|
|
252
267
|
| **Any local interactive terminal, already installed** | `sap-ai-dev --setup` | Re-runs the wizard to add, update, or remove generated MCP entries. Plain `sap-ai-dev` with no arguments also starts local setup when no BAS environment is detected. |
|
|
253
268
|
| **npm install hook did not show a wizard** | `sap-ai-dev --setup` | This is expected on some npm/Windows combinations because lifecycle scripts may run without an interactive console. The explicit setup command is the reliable path. |
|
|
254
269
|
| **Force npm to show install-time script output** | `npm install --global --foreground-scripts sap-ai-dev-toolkit` | Optional troubleshooting mode if you specifically want to see postinstall output during installation. Still run `sap-ai-dev --setup` afterwards if no destinations were configured. |
|
|
255
|
-
|
|
270
|
+
|
|
271
|
+
### 🪟 Windows notes
|
|
272
|
+
|
|
273
|
+
Everything works natively on Windows — PowerShell, cmd, and Windows Terminal are all supported:
|
|
274
|
+
|
|
275
|
+
- `npm install --global sap-ai-dev-toolkit` puts three commands on `PATH`: `sap-ai-dev`, its alias `sap-ai-dev-toolkit`, and `sap-ai-hana`. If `sap-ai-dev` is reported as unknown, npm's global bin directory is missing from `PATH` (run `npm prefix -g` to find it) or the install did not complete — `npx --yes --ignore-scripts sap-ai-dev-toolkit --setup` works without any global install.
|
|
276
|
+
- The pinned VSP runtime ships inside the package as a native `vsp-win32-x64.exe`; no Go toolchain, WSL, or extra downloads are required.
|
|
277
|
+
- MCP entries that launch through npm are written as `cmd /c npx ...` on Windows — the form VS Code, Claude Code, and Cursor can spawn there. Entries written on Linux/macOS use plain `npx`.
|
|
278
|
+
- Setup reads `mcp.json` even when Windows tools saved it with a UTF-8/UTF-16 BOM (PowerShell `>`/`Set-Content`, Notepad "UTF-8 with BOM") and rewrites it as plain UTF-8. If the existing file cannot be used as-is, setup self-heals before falling back to a reset: VS Code-style comments and trailing commas (JSONC) are recovered in place, torn reads from a concurrent editor save are re-read once, and broken `servers`/`inputs` fields are repaired individually. Every lossy change first backs up the original next to the real file as `mcp.json.healed|repaired|invalid-<timestamp>.bak` (exclusive create, so reruns never overwrite an earlier backup; symlinked configs keep their link). `sap-ai-dev --doctor` stays strict and reports such files instead of rewriting them — run `sap-ai-dev --setup` to heal.
|
|
279
|
+
- Local SAP GUI discovery reads the Windows landscape files and registry, and setup can write an experimental Windows SSO (`Negotiate`/SPNEGO) entry; see the “Local machine SAP GUI support” section below.
|
|
256
280
|
|
|
257
281
|
If you previously installed the old package, remove it first because the legacy `bas-vsp-mcp` command is no longer shipped:
|
|
258
282
|
|
|
@@ -312,9 +336,9 @@ The same postinstall offers the bundled agents and skills for several AI coding
|
|
|
312
336
|
|
|
313
337
|
Some current npm versions also require install hooks to be approved. If npm reports that `sap-ai-dev-toolkit`'s `postinstall` was blocked, allow it during a fresh install with `npm install --global --allow-scripts=sap-ai-dev-toolkit sap-ai-dev-toolkit`, or run `sap-ai-dev --setup` from an interactive terminal after installation.
|
|
314
338
|
|
|
315
|
-
The setup report uses icons and terminal colors; set `NO_COLOR=1` to disable ANSI colors. The table is a weather report, not a bouncer: green **PASS** means the ADT probe responded, red **FAIL** means it failed, and yellow **SKIPPED** means it was skipped. Probe failures do not block MCP registration or startup for destinations you select.
|
|
339
|
+
The setup report uses icons and terminal colors; set `NO_COLOR=1` to disable ANSI colors. Long-running steps (BAS destination discovery, ADT probes, VSP runtime provisioning, `--doctor` checks) show an animated progress line so the terminal never looks stuck; it appears only on interactive terminals and is disabled automatically on CI — set `SAP_AI_DEV_TOOLKIT_DISABLE_SCAN_ANIMATION=true` to turn it off. The table is a weather report, not a bouncer: green **PASS** means the ADT probe responded, red **FAIL** means it failed, and yellow **SKIPPED** means it was skipped. Probe failures do not block MCP registration or startup for destinations you select.
|
|
316
340
|
|
|
317
|
-
Run `sap-ai-dev --setup` later to change the destination selection or remove generated destination entries. Run `sap-ai-dev --setup --tools` to also choose optional companion tools.
|
|
341
|
+
Run `sap-ai-dev --setup` later to change the destination selection or remove generated destination entries. Run `sap-ai-dev --setup --tools` to also choose optional companion tools. Generated destination entries use the global `sap-ai-dev` command when it is on `PATH`; otherwise (for example after a pure npx setup) they automatically launch the pinned package version through `npx`, routed through `cmd /c npx` on Windows so every MCP host can spawn them. Pass `--npx` to force the npx launcher even when a global command exists; companion tools already use `npx` with their own npm packages.
|
|
318
342
|
|
|
319
343
|
If a setup step is skipped or fails, rerun it from an interactive terminal:
|
|
320
344
|
|
|
@@ -325,10 +349,10 @@ sap-ai-dev --setup
|
|
|
325
349
|
To configure without a global install, run the guided setup directly through npm:
|
|
326
350
|
|
|
327
351
|
```sh
|
|
328
|
-
npx --yes --ignore-scripts
|
|
352
|
+
npx --yes --ignore-scripts sap-ai-dev-toolkit --setup
|
|
329
353
|
```
|
|
330
354
|
|
|
331
|
-
This lists discovered systems in the same checkbox picker, with nothing selected by default. Use **Space** to choose destinations, **Enter** to confirm, and **a** to toggle all (select all if any are unchecked; otherwise clear the selection).
|
|
355
|
+
This lists discovered systems in the same checkbox picker, with nothing selected by default. Use **Space** to choose destinations, **Enter** to confirm, and **a** to toggle all (select all if any are unchecked; otherwise clear the selection). Because no global `sap-ai-dev` command is on `PATH`, it writes MCP entries that launch the pinned package version through npx — through `cmd /c npx` on Windows, which is the form VS Code, Claude Code, and Cursor can spawn there. `--ignore-scripts` avoids running the install-time wizard a second time; add `--npx` to force this launcher shape even when the toolkit is installed globally. After setup, in BAS or VS Code run **MCP: List Servers**, select each chosen destination, and choose **Start Server**.
|
|
332
356
|
|
|
333
357
|
For a non-interactive installation or a platform without a published VSP asset, provide a trusted binary override:
|
|
334
358
|
|
|
@@ -423,7 +447,7 @@ Review skipped or conflicting files and merge changes manually. `LintABAP` analy
|
|
|
423
447
|
If the `cf` CLI is version 8.18 or newer and authenticated to a targeted space, setup offers an opt-in import from that space's SAP Destination service. It does not create service keys unless you accept. The imported records are limited to the current space and are merged with BAS destinations for selection. OnPremise destinations require choosing a Connectivity service instance; runtime traffic uses that service's proxy, and PrincipalPropagation uses the current CF user's token. Internet destinations use the configured HTTP(S) proxy environment. Setup stores only service-instance/key references in `mcp.json`, not the service-key credentials. At runtime the generated entry verifies the active CF space and resolves those references. Setup removes an add-on-created key only when no remaining CF entry references it and the CLI is targeted to the key's recorded space; keys are left untouched when the space cannot be verified.
|
|
424
448
|
Cloud Foundry HTTP destination probes use forward-form HTTP requests, not CONNECT tunnels; SAP Connectivity requires HTTP from the application to its proxy. OnPremise requests are canceled when the client disconnects or the runtime route closes.
|
|
425
449
|
|
|
426
|
-
Interactive npm install and `sap-ai-dev --setup` both use a checkbox picker with no destinations selected by default. Use **Space** to choose destinations, **Enter** to confirm, and **a** to toggle all (select all if any are unchecked; otherwise clear the selection). Confirming with none checked removes this package's generated MCP entries. Non-interactive installs skip destination or local SAP GUI system selection without changing MCP config. Run the interactive setup command above;
|
|
450
|
+
Interactive npm install and `sap-ai-dev --setup` both use a checkbox picker with no destinations selected by default. Use **Space** to choose destinations, **Enter** to confirm, and **a** to toggle all (select all if any are unchecked; otherwise clear the selection). Confirming with none checked removes this package's generated MCP entries. Non-interactive installs skip destination or local SAP GUI system selection without changing MCP config. Run the interactive setup command above; generated entries fall back to the pinned npx launcher automatically when the package is not installed globally.
|
|
427
451
|
|
|
428
452
|
Setup tidies its own footprint: it reconciles MCP entries managed by this package and removes legacy `sapAiDev_*` / `basVspMcp_*` entries from earlier releases. Existing unrelated MCP servers and top-level configuration such as `inputs` stay untouched.
|
|
429
453
|
|
|
@@ -833,6 +857,8 @@ Runtime diagnostics take the stderr lane. In the MCP server's Output view, check
|
|
|
833
857
|
|
|
834
858
|
If `GetSystemInfo` or `RunQuery` reports a CSRF-token fetch failure, VSP tries `/sap/bc/adt/core/discovery` first and falls back to `/sap/bc/adt/discovery` when the core endpoint returns HTTP 404 or 5xx without a token. If both endpoints fail, the error includes their HTTP statuses; check BAS destination routing and the SAP ADT service. `GetInstalledComponents` is a separate GET request and a failure there is not evidence of a CSRF problem.
|
|
835
859
|
|
|
860
|
+
**`MCP config ... contains invalid JSON`** usually means one of three things, all healed automatically by setup: the file starts with a byte-order mark (Windows PowerShell `>` redirection, `Set-Content`, or Notepad's "UTF-8 with BOM" mode — invisible in editors and terminals), it contains comments or trailing commas (VS Code reads `mcp.json` as JSONC), or setup raced an editor save and read a torn file (retried once before healing). Unrecoverable content is backed up as `mcp.json.<kind>-<timestamp>.bak` and replaced; the backup path is printed in the setup warning. On older versions, re-save the file as UTF-8 without BOM (VS Code: "Change File Encoding → Save with Encoding → UTF-8") and rerun `sap-ai-dev --setup`.
|
|
861
|
+
|
|
836
862
|
If automatic Go or VSP provisioning fails, check network access and the package's supported platform. You can install Go manually or set `GO_BINARY` as an explicit fallback. A trusted prebuilt VSP can be supplied with `SAP_AI_DEV_TOOLKIT_BINARY`.
|
|
837
863
|
|
|
838
864
|
## 🧑💻 Development
|
|
@@ -857,7 +883,7 @@ NPM_PUBLISH_TOKEN=npm_...
|
|
|
857
883
|
- To publish a patch bump, run `npm run publish:npm -- --patch`; it updates `package.json` and `package-lock.json` before publishing. If publishing fails after the bump, retry without `--patch`.
|
|
858
884
|
- npm's `prepublishOnly` hook builds the pinned VSP binaries for all platforms (including the Go toolchain if missing) and refuses to ship a tarball whose `dist/checksums.txt` does not list all five assets; it guards `npm run publish:npm` and a plain `npm publish` alike. Pass `--skip-build` to republish an already-built `dist/` without rebuilding.
|
|
859
885
|
- Preview the package without publishing, changing its version, or building with `npm run publish:npm -- --dry-run`.
|
|
860
|
-
- GitHub Actions runs the test suite on pushes to `main` and pull requests. A push to `main` that changes the `package.json` version runs the same checks, builds the VSP binaries,
|
|
886
|
+
- GitHub Actions runs the test suite on pushes to `main` and pull requests. A push to `main` that changes the `package.json` version runs the same checks, builds the VSP binaries, publishes that version to npm, and creates a `v<version>` GitHub release with generated notes.
|
|
861
887
|
- The publish workflow uses the `NPM_PUBLISH_TOKEN` GitHub repository secret. Verify it with `gh secret list`; the token value is never stored in the repository.
|
|
862
888
|
|
|
863
889
|
The token is not printed or stored in the repository.
|
package/dist/checksums.txt
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
85333f1ac7cbc25bc10a938cea5712b43d1c7b241911f19c6794f897dbcb589c vsp-linux-x64
|
|
2
|
+
68cd2b79e7cda145135f9f516b6ba76f33839bc9226bbf58ea015ff2442b4b2d vsp-linux-arm64
|
|
3
|
+
62fd8cd8eba8d61e36813c098938a2479af68047fd2fa1c85782edb4d4b5261b vsp-darwin-x64
|
|
4
|
+
71eb9e64e292e3ac83a2ab6a630367cfb0d79fe465d62ad1cfb05ef8c6c2b6e7 vsp-darwin-arm64
|
|
5
|
+
952a6d5a90265fb6a6b3494d01a5d143fd7c471f7ce560bed200c399522b180f vsp-win32-x64.exe
|
package/dist/vsp-darwin-arm64
CHANGED
|
Binary file
|
package/dist/vsp-darwin-x64
CHANGED
|
Binary file
|
package/dist/vsp-linux-arm64
CHANGED
|
Binary file
|
package/dist/vsp-linux-x64
CHANGED
|
Binary file
|
package/dist/vsp-win32-x64.exe
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sap-ai-dev-toolkit",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.3",
|
|
4
4
|
"description": "SAP AI development toolkit for BAS, ABAP, RAP, CAP, HANA Cloud, Fiori, UI5, and MCP",
|
|
5
5
|
"author": "Gurkan Yilmaz",
|
|
6
6
|
"type": "module",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
},
|
|
18
18
|
"bin": {
|
|
19
19
|
"sap-ai-dev": "src/launcher.mjs",
|
|
20
|
+
"sap-ai-dev-toolkit": "src/launcher.mjs",
|
|
20
21
|
"sap-ai-hana": "src/hana-inspector.mjs"
|
|
21
22
|
},
|
|
22
23
|
"scripts": {
|
package/scripts/postinstall.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { installMcpServersForHarnesses } from '../src/harness-mcp-config.mjs';
|
|
|
9
9
|
import { DEFAULT_HARNESS_IDS, HARNESSES, HARNESS_ENV_VAR, NON_INTERACTIVE_HARNESS_IDS, envHarnessSelection, harnessRoot } from '../src/harnesses.mjs';
|
|
10
10
|
import { ReadStream as TTYReadStream, WriteStream as TTYWriteStream } from 'node:tty';
|
|
11
11
|
import { homedir } from 'node:os';
|
|
12
|
-
import { checkboxPrompt, colorText, formatStatus } from '../src/terminal-ui.mjs';
|
|
12
|
+
import { checkboxPrompt, colorText, formatStatus, startProgress } from '../src/terminal-ui.mjs';
|
|
13
13
|
import { brandedEnvValue, withBrandedEnvironment } from '../src/branding.mjs';
|
|
14
14
|
import { generatedServerName } from '../src/mcp-config.mjs';
|
|
15
15
|
import { redactText } from '../src/redact.mjs';
|
|
@@ -232,7 +232,7 @@ async function announceSetup(result) {
|
|
|
232
232
|
return;
|
|
233
233
|
}
|
|
234
234
|
if (result?.reason === 'non-tty') {
|
|
235
|
-
await announce('Destination selection was skipped because npm did not provide an interactive terminal.\nMCP config was not changed.\nRun sap-ai-dev --setup from an interactive
|
|
235
|
+
await announce('Destination selection was skipped because npm did not provide an interactive terminal.\nMCP config was not changed.\nRun sap-ai-dev --setup from an interactive terminal, or run npx --yes --ignore-scripts sap-ai-dev-toolkit --setup from any terminal.', 'warning');
|
|
236
236
|
return;
|
|
237
237
|
}
|
|
238
238
|
if (result?.reason === 'no-destinations') {
|
|
@@ -304,11 +304,15 @@ async function main() {
|
|
|
304
304
|
if (brandedEnvValue(runtimeEnv, 'BINARY')) {
|
|
305
305
|
await announce('Using the SAP_AI_DEV_TOOLKIT_BINARY override.', 'info');
|
|
306
306
|
} else {
|
|
307
|
-
|
|
307
|
+
// The bundled binary resolves in milliseconds; the animation only
|
|
308
|
+
// appears when provisioning actually takes time (download fallback).
|
|
309
|
+
const stopBinaryProgress = startProgress('Preparing the pinned VSP runtime for this platform', { output: process.stderr, env: runtimeEnv });
|
|
308
310
|
try {
|
|
309
311
|
await installBinary(pkg, { env: runtimeEnv });
|
|
312
|
+
stopBinaryProgress();
|
|
310
313
|
await announce('Pinned VSP binary installed and ready.', 'success');
|
|
311
314
|
} catch (error) {
|
|
315
|
+
stopBinaryProgress();
|
|
312
316
|
throw new Error(`VSP binary provisioning failed: ${error.message}`);
|
|
313
317
|
}
|
|
314
318
|
}
|
|
@@ -3,6 +3,7 @@ import { homedir } from 'node:os';
|
|
|
3
3
|
import { basename, dirname, join } from 'node:path';
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
5
|
import { harnessById, harnessRoot } from './harnesses.mjs';
|
|
6
|
+
import { decodeJsonFileBuffer } from './mcp-config.mjs';
|
|
6
7
|
|
|
7
8
|
const MANAGED_ENV = 'SAP_AI_DEV_TOOLKIT_MANAGED';
|
|
8
9
|
|
|
@@ -24,13 +25,15 @@ function mcpServersPath(harness, { home = process.env.HOME || homedir(), env = p
|
|
|
24
25
|
async function readJsonConfig(path) {
|
|
25
26
|
let raw;
|
|
26
27
|
try {
|
|
27
|
-
raw = await readFile(path
|
|
28
|
+
raw = await readFile(path);
|
|
28
29
|
} catch (error) {
|
|
29
30
|
if (error.code === 'ENOENT') return {};
|
|
30
31
|
throw error;
|
|
31
32
|
}
|
|
32
33
|
try {
|
|
33
|
-
|
|
34
|
+
// decodeJsonFileBuffer tolerates the UTF-8 BOM / UTF-16 encodings that
|
|
35
|
+
// Windows tools commonly write (see mcp-config.mjs).
|
|
36
|
+
const parsed = JSON.parse(decodeJsonFileBuffer(raw));
|
|
34
37
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('top-level value is not an object');
|
|
35
38
|
return parsed;
|
|
36
39
|
} catch (error) {
|
package/src/launcher.mjs
CHANGED
|
@@ -7,13 +7,14 @@ import { discoverDestinations, remediation, slugifyDestination, statusRows } fro
|
|
|
7
7
|
import { discoverSapGuiSystems } from './local-sap-gui.mjs';
|
|
8
8
|
import { findBinary } from './binary.mjs';
|
|
9
9
|
import { MCPProxy } from './mcp-proxy.mjs';
|
|
10
|
-
import { installMcpConfig, repairManagedMcpConfig } from './mcp-config.mjs';
|
|
10
|
+
import { installMcpConfig, npxMcpLauncher, repairManagedMcpConfig } from './mcp-config.mjs';
|
|
11
11
|
import { runSetup } from './setup.mjs';
|
|
12
12
|
import { resolveConfiguredCloudFoundryDestination } from './cf-destination.mjs';
|
|
13
13
|
import { createWindowsSsoAdtProxy } from './windows-sso-adt-proxy.mjs';
|
|
14
14
|
import { createTlsServerNameAdtProxy } from './tls-adt-proxy.mjs';
|
|
15
15
|
import { withBrandedEnvironment } from './branding.mjs';
|
|
16
16
|
import { redactText } from './redact.mjs';
|
|
17
|
+
import { startProgress } from './terminal-ui.mjs';
|
|
17
18
|
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
18
19
|
const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
|
|
19
20
|
const runtimeEnv = withBrandedEnvironment(process.env);
|
|
@@ -53,7 +54,9 @@ function doctorRow(name, stage, status, detail) {
|
|
|
53
54
|
|
|
54
55
|
async function runDoctor(destinations) {
|
|
55
56
|
const checks = [];
|
|
57
|
+
const stopProgress = startProgress(destinations.length === 1 ? `Checking ${destinations[0]?.name || 'destination'}` : `Checking ${destinations.length} destinations`, { output: process.stderr, label: 'Doctor' });
|
|
56
58
|
if (!destinations.length) {
|
|
59
|
+
stopProgress();
|
|
57
60
|
return { ok: false, destinations: 0, checks: [doctorRow('-', 'destination discovery', 'failed', 'No destinations were found. Check H2O_URL or the configured Cloud Foundry destination.')] };
|
|
58
61
|
}
|
|
59
62
|
let binary;
|
|
@@ -76,6 +79,7 @@ async function runDoctor(destinations) {
|
|
|
76
79
|
checks.push(doctorRow('-', 'MCP config repair', 'failed', redactText(error.message || error).slice(0, 300)));
|
|
77
80
|
}
|
|
78
81
|
for (const destination of destinations) {
|
|
82
|
+
stopProgress.update(`Checking ${destination.name}: ADT probe, VSP startup, tools/list`);
|
|
79
83
|
const probe = destination.probe;
|
|
80
84
|
const probeStatus = !probe || probe.status === 'skipped' ? 'skipped' : (probe.available === true ? 'passed' : 'failed');
|
|
81
85
|
checks.push(doctorRow(destination.name, 'ADT probe', probeStatus, probe?.status || 'No BAS ADT probe was supplied; GetSystemInfo will check the route.'));
|
|
@@ -126,6 +130,7 @@ async function runDoctor(destinations) {
|
|
|
126
130
|
}
|
|
127
131
|
}
|
|
128
132
|
const required = checks.filter(check => check.status !== 'skipped');
|
|
133
|
+
stopProgress();
|
|
129
134
|
return { ok: required.every(check => check.status === 'passed'), destinations: destinations.length, checks };
|
|
130
135
|
}
|
|
131
136
|
|
|
@@ -253,11 +258,17 @@ async function main() {
|
|
|
253
258
|
if (doctor) {
|
|
254
259
|
let report;
|
|
255
260
|
try {
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
+
const stopDiscoveryProgress = startProgress('Discovering SAP destinations', { output: process.stderr, env: runtimeEnv, label: 'Doctor' });
|
|
262
|
+
let destinations;
|
|
263
|
+
try {
|
|
264
|
+
destinations = runtimeEnv.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'cloud-foundry'
|
|
265
|
+
? [await resolveConfiguredCloudFoundryDestination({ env: runtimeEnv })]
|
|
266
|
+
: (runtimeEnv.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'sap-gui-local'
|
|
267
|
+
? [await configuredLocalSapGuiDestination(runtimeEnv)]
|
|
268
|
+
: await discoverForCommand());
|
|
269
|
+
} finally {
|
|
270
|
+
stopDiscoveryProgress();
|
|
271
|
+
}
|
|
261
272
|
report = await runDoctor(destinations);
|
|
262
273
|
} catch (error) {
|
|
263
274
|
report = { ok: false, destinations: 0, checks: [doctorRow('-', 'destination discovery', 'failed', redactText(error.message || error).slice(0, 300))] };
|
|
@@ -272,17 +283,26 @@ async function main() {
|
|
|
272
283
|
includeSapDevelopmentToolsPrompt: hasFlag('--tools') || hasFlag('--companion-tools')
|
|
273
284
|
};
|
|
274
285
|
if (hasFlag('--npx')) {
|
|
286
|
+
// Windows has no npx.exe, so generated entries there launch through
|
|
287
|
+
// `cmd /c npx`; other platforms use npx directly.
|
|
288
|
+
const launcher = npxMcpLauncher();
|
|
275
289
|
setupOptions.install = (selected, options) => installMcpConfig(selected, {
|
|
276
290
|
...options,
|
|
277
|
-
command:
|
|
278
|
-
args: ['--yes', '--ignore-scripts', `--package=sap-ai-dev-toolkit@${pkg.version}`, 'sap-ai-dev']
|
|
291
|
+
command: launcher.command,
|
|
292
|
+
args: [...launcher.prefixArgs, '--yes', '--ignore-scripts', `--package=sap-ai-dev-toolkit@${pkg.version}`, 'sap-ai-dev']
|
|
279
293
|
});
|
|
280
294
|
}
|
|
281
295
|
await runSetup(setupOptions);
|
|
282
296
|
return;
|
|
283
297
|
}
|
|
284
298
|
if (check || list) {
|
|
285
|
-
const
|
|
299
|
+
const stopProgress = startProgress('Discovering SAP destinations and probing ADT endpoints', { output: process.stderr, env: runtimeEnv });
|
|
300
|
+
let destinations;
|
|
301
|
+
try {
|
|
302
|
+
destinations = await discoverForCommand();
|
|
303
|
+
} finally {
|
|
304
|
+
stopProgress();
|
|
305
|
+
}
|
|
286
306
|
const statuses = statusRows(destinations);
|
|
287
307
|
if (list && hasFlag('--json')) json(statuses);
|
|
288
308
|
else for (const row of statuses) console.error(`${row.name}: client=${row.client} authentication=${row.authentication} probe=${row.probe}`);
|
|
@@ -319,11 +339,15 @@ async function main() {
|
|
|
319
339
|
return;
|
|
320
340
|
}
|
|
321
341
|
const binary = await binaryOrError();
|
|
322
|
-
// Windows cannot exec
|
|
342
|
+
// Windows cannot exec .js/.mjs files directly; route JavaScript entries
|
|
323
343
|
// (test fixtures) through the current Node binary like MCPProxy does.
|
|
344
|
+
// Windows .cmd/.bat shims (e.g. a SAP_AI_DEV_TOOLKIT_BINARY override)
|
|
345
|
+
// must go through cmd /c; Node refuses to spawn batch files directly.
|
|
324
346
|
const spawnTarget = /\.(?:mjs|cjs|js)$/i.test(binary)
|
|
325
347
|
? { command: process.execPath, prefixArgs: [binary] }
|
|
326
|
-
:
|
|
348
|
+
: (/\.(?:cmd|bat)$/i.test(binary)
|
|
349
|
+
? { command: 'cmd', prefixArgs: ['/c', binary] }
|
|
350
|
+
: { command: binary, prefixArgs: [] });
|
|
327
351
|
const child = spawn(spawnTarget.command, [...spawnTarget.prefixArgs, ...process.argv.slice(2)], { env: runtimeEnv, stdio: 'inherit' });
|
|
328
352
|
process.once('SIGINT', () => child.kill('SIGINT'));
|
|
329
353
|
process.once('SIGTERM', () => child.kill('SIGTERM'));
|
|
@@ -336,7 +360,13 @@ async function main() {
|
|
|
336
360
|
return;
|
|
337
361
|
}
|
|
338
362
|
|
|
339
|
-
const
|
|
363
|
+
const stopProgress = startProgress('Discovering BAS destinations and probing ADT endpoints', { output: process.stderr, env: runtimeEnv });
|
|
364
|
+
let discovered;
|
|
365
|
+
try {
|
|
366
|
+
discovered = await discoverDestinations({ env: runtimeEnv });
|
|
367
|
+
} finally {
|
|
368
|
+
stopProgress();
|
|
369
|
+
}
|
|
340
370
|
for (const destination of discovered) logLine(probeDiagnostic(destination));
|
|
341
371
|
const destinations = discovered;
|
|
342
372
|
if (!destinations.length) {
|
package/src/mcp-config.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { constants } from 'node:fs';
|
|
3
|
-
import { access, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { access, mkdir, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
4
5
|
import { homedir, platform } from 'node:os';
|
|
5
6
|
import { basename, delimiter, dirname, join, resolve } from 'node:path';
|
|
6
7
|
import { fileURLToPath } from 'node:url';
|
|
@@ -79,6 +80,26 @@ async function exists(path) {
|
|
|
79
80
|
}
|
|
80
81
|
}
|
|
81
82
|
|
|
83
|
+
// Windows tools (PowerShell `>` redirection and Set-Content, Notepad "UTF-8
|
|
84
|
+
// with BOM", several editors) write mcp.json with a byte-order mark or even
|
|
85
|
+
// UTF-16. JSON.parse rejects a leading BOM (rendering invisibly as
|
|
86
|
+
// "Unexpected token ''"), so decode defensively before parsing.
|
|
87
|
+
export function decodeJsonFileBuffer(buffer) {
|
|
88
|
+
if (!Buffer.isBuffer(buffer)) return String(buffer ?? '').replace(/^/, '');
|
|
89
|
+
if (buffer.length >= 2) {
|
|
90
|
+
if (buffer[0] === 0xFF && buffer[1] === 0xFE) return buffer.subarray(2).toString('utf16le');
|
|
91
|
+
if (buffer[0] === 0xFE && buffer[1] === 0xFF) {
|
|
92
|
+
let body = buffer.subarray(2);
|
|
93
|
+
if (body.length % 2) body = body.subarray(0, body.length - 1);
|
|
94
|
+
return Buffer.from(body).swap16().toString('utf16le');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const utf8 = (buffer.length >= 3 && buffer[0] === 0xEF && buffer[1] === 0xBB && buffer[2] === 0xBF)
|
|
98
|
+
? buffer.subarray(3).toString('utf8')
|
|
99
|
+
: buffer.toString('utf8');
|
|
100
|
+
return utf8.replace(/^\uFEFF/, '');
|
|
101
|
+
}
|
|
102
|
+
|
|
82
103
|
export async function resolveMcpServerCommand(env = process.env) {
|
|
83
104
|
const executableNames = process.platform === 'win32'
|
|
84
105
|
? ['sap-ai-dev.cmd', 'sap-ai-dev.exe', 'sap-ai-dev']
|
|
@@ -98,6 +119,44 @@ export async function resolveMcpServerCommand(env = process.env) {
|
|
|
98
119
|
return 'sap-ai-dev';
|
|
99
120
|
}
|
|
100
121
|
|
|
122
|
+
// Windows ships npx as npx.cmd, not npx.exe: MCP hosts spawn the configured
|
|
123
|
+
// command directly without shell PATH resolution, so npx-launched servers
|
|
124
|
+
// must route through `cmd /c npx` there (the form VS Code, Claude Code, and
|
|
125
|
+
// Cursor document for Windows). Other platforms run npx directly.
|
|
126
|
+
export function npxMcpLauncher(platform = process.platform) {
|
|
127
|
+
return platform === 'win32'
|
|
128
|
+
? { command: 'cmd', prefixArgs: ['/c', 'npx'] }
|
|
129
|
+
: { command: 'npx', prefixArgs: [] };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Normalizes the argument list of npx-launched entries, whether written as
|
|
133
|
+
// `npx ...` or the Windows `cmd /c npx ...` form; returns null when the entry
|
|
134
|
+
// is not npx-launched.
|
|
135
|
+
function npxLaunchArgs(entry) {
|
|
136
|
+
const args = Array.isArray(entry?.args) ? entry.args.map(String) : [];
|
|
137
|
+
if (entry?.command === 'npx') return args;
|
|
138
|
+
const commandName = typeof entry?.command === 'string'
|
|
139
|
+
? entry.command.slice(Math.max(entry.command.lastIndexOf('/'), entry.command.lastIndexOf('\\')) + 1)
|
|
140
|
+
: '';
|
|
141
|
+
if (/^cmd(?:\.exe)?$/i.test(commandName) && args[0]?.toLowerCase() === '/c' && /^npx(?:\.cmd)?$/i.test(args[1] || '')) {
|
|
142
|
+
return args.slice(2);
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Resolves how generated destination entries launch the toolkit: the global
|
|
148
|
+
// sap-ai-dev command when it is on PATH, otherwise the pinned package
|
|
149
|
+
// through npx (routed through cmd /c on Windows) so entries written by a
|
|
150
|
+
// pure `npx sap-ai-dev-toolkit --setup` run still start on every host.
|
|
151
|
+
async function resolveLauncherInstallCommand(env) {
|
|
152
|
+
const resolved = await resolveMcpServerCommand(env);
|
|
153
|
+
if (resolved !== 'sap-ai-dev') return { command: resolved, args: null };
|
|
154
|
+
const { command, prefixArgs } = npxMcpLauncher();
|
|
155
|
+
const selfVersion = await runningPackageVersion();
|
|
156
|
+
const packageSpec = selfVersion ? `sap-ai-dev-toolkit@${selfVersion}` : 'sap-ai-dev-toolkit';
|
|
157
|
+
return { command, args: [...prefixArgs, '--yes', '--ignore-scripts', `--package=${packageSpec}`, 'sap-ai-dev'] };
|
|
158
|
+
}
|
|
159
|
+
|
|
101
160
|
export async function resolveMcpConfigPath(env = process.env) {
|
|
102
161
|
const configuredPath = [env.SAP_AI_DEV_MCP_CONFIG, brandedEnvValue(env, 'MCP_CONFIG')]
|
|
103
162
|
.find(value => typeof value === 'string' && value.length > 0);
|
|
@@ -138,16 +197,17 @@ export function generatedDestinationServerName(destination) {
|
|
|
138
197
|
return generatedServerName(destination?.source === 'cloud-foundry' ? destination.serverName : destination?.name);
|
|
139
198
|
}
|
|
140
199
|
|
|
141
|
-
export function buildSapDevelopmentMcpEntries(serverIds = SAP_DEVELOPMENT_MCP_SERVERS.map(server => server.id), { packageVersions = {}, packageManager = 'npx' } = {}) {
|
|
200
|
+
export function buildSapDevelopmentMcpEntries(serverIds = SAP_DEVELOPMENT_MCP_SERVERS.map(server => server.id), { packageVersions = {}, packageManager = 'npx', platform = process.platform } = {}) {
|
|
142
201
|
const selected = new Set(serverIds);
|
|
143
202
|
const entries = Object.create(null);
|
|
203
|
+
const launcher = packageManager === 'npx' ? npxMcpLauncher(platform) : { command: packageManager, prefixArgs: [] };
|
|
144
204
|
for (const server of SAP_DEVELOPMENT_MCP_SERVERS) {
|
|
145
205
|
if (!selected.has(server.id)) continue;
|
|
146
206
|
const packageSpec = packageVersions[server.id] || packageVersions[server.packageName] || server.packageName;
|
|
147
207
|
entries[server.id] = {
|
|
148
208
|
type: 'stdio',
|
|
149
|
-
command:
|
|
150
|
-
args: ['--yes', ...(server.ignoreScripts ? ['--ignore-scripts'] : []), `--package=${packageSpec}`, server.bin],
|
|
209
|
+
command: launcher.command,
|
|
210
|
+
args: [...launcher.prefixArgs, '--yes', ...(server.ignoreScripts ? ['--ignore-scripts'] : []), `--package=${packageSpec}`, server.bin],
|
|
151
211
|
BAS_EXT: 'true',
|
|
152
212
|
BAS_EXT_KIND: COMPANION_SERVER_KIND,
|
|
153
213
|
displayName: server.name,
|
|
@@ -232,16 +292,14 @@ function isPackageLauncher(entry) {
|
|
|
232
292
|
const legacyCommand = legacyCommands.has(entry?.command) || legacyCommands.has(commandName);
|
|
233
293
|
const currentPackages = [/^--package=sap-ai-dev-toolkit(?:@[^/]+)?$/];
|
|
234
294
|
const legacyPackages = [/^--package=bas-mcp-addon(?:@[^/]+)?$/];
|
|
235
|
-
const hasPackage = patterns =>
|
|
236
|
-
|
|
237
|
-
const currentNpxLauncher =
|
|
238
|
-
&&
|
|
239
|
-
&&
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
&&
|
|
243
|
-
&& entry.args.some(argument => legacyCommands.has(argument))
|
|
244
|
-
&& hasPackage(legacyPackages);
|
|
295
|
+
const hasPackage = (arguments_, patterns) => arguments_.some(argument => patterns.some(pattern => pattern.test(argument)));
|
|
296
|
+
const npxArgs = npxLaunchArgs(entry);
|
|
297
|
+
const currentNpxLauncher = npxArgs !== null
|
|
298
|
+
&& npxArgs.some(argument => currentCommands.has(argument))
|
|
299
|
+
&& hasPackage(npxArgs, currentPackages);
|
|
300
|
+
const legacyNpxLauncher = npxArgs !== null
|
|
301
|
+
&& npxArgs.some(argument => legacyCommands.has(argument))
|
|
302
|
+
&& hasPackage(npxArgs, legacyPackages);
|
|
245
303
|
// Current launchers are treated as managed only when tagged by this add-on.
|
|
246
304
|
// Some early bas-mcp-addon entries were not tagged with BAS_EXT, though, and
|
|
247
305
|
// leaving them behind keeps advertising the old full VSP surface (~159 tools).
|
|
@@ -264,10 +322,10 @@ function isManagedBasDestinationEntry(entry) {
|
|
|
264
322
|
? entry.command.slice(Math.max(entry.command.lastIndexOf('/'), entry.command.lastIndexOf('\\')) + 1).replace(/\.(?:cmd|exe)$/i, '')
|
|
265
323
|
: '';
|
|
266
324
|
const currentCommand = currentCommands.has(entry?.command) || currentCommands.has(commandName);
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
&&
|
|
270
|
-
&&
|
|
325
|
+
const npxArgs = npxLaunchArgs(entry);
|
|
326
|
+
const currentNpxLauncher = npxArgs !== null
|
|
327
|
+
&& npxArgs.some(argument => currentCommands.has(argument))
|
|
328
|
+
&& npxArgs.some(argument => /^--package=sap-ai-dev-toolkit(?:@[^/]+)?$/.test(argument));
|
|
271
329
|
return entry?.BAS_EXT === 'true'
|
|
272
330
|
&& (currentCommand || currentNpxLauncher)
|
|
273
331
|
&& typeof destinationValue(entry?.env) === 'string'
|
|
@@ -299,36 +357,186 @@ export function collectCloudFoundryKeyReferencesFromAllEntries(config) {
|
|
|
299
357
|
return collectCloudFoundryKeyReferences(config, false);
|
|
300
358
|
}
|
|
301
359
|
|
|
302
|
-
|
|
303
|
-
|
|
360
|
+
function freshConfig() {
|
|
361
|
+
return { servers: {}, inputs: [] };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// VS Code parses mcp.json as JSONC: // and /* */ comments plus trailing
|
|
365
|
+
// commas are accepted there but rejected by strict JSON.parse. Strip them in
|
|
366
|
+
// one string-aware pass (string literals are copied verbatim, so "//", "/*",
|
|
367
|
+
// and "," inside values survive) so self-healing can recover hand-written
|
|
368
|
+
// content instead of resetting the file.
|
|
369
|
+
export function loosenJsonText(text) {
|
|
370
|
+
let out = '';
|
|
371
|
+
let i = 0;
|
|
372
|
+
const n = text.length;
|
|
373
|
+
while (i < n) {
|
|
374
|
+
const ch = text[i];
|
|
375
|
+
if (ch === '"') {
|
|
376
|
+
let j = i + 1;
|
|
377
|
+
while (j < n && text[j] !== '"') j += text[j] === '\\' ? 2 : 1;
|
|
378
|
+
out += text.slice(i, Math.min(j + 1, n));
|
|
379
|
+
i = Math.min(j + 1, n);
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
if (ch === '/' && text[i + 1] === '/') {
|
|
383
|
+
i += 2;
|
|
384
|
+
while (i < n && text[i] !== '\n' && text[i] !== '\r') i += 1;
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
if (ch === '/' && text[i + 1] === '*') {
|
|
388
|
+
i += 2;
|
|
389
|
+
while (i < n && !(text[i] === '*' && text[i + 1] === '/')) i += 1;
|
|
390
|
+
i = Math.min(i + 2, n);
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
if (ch === ',') {
|
|
394
|
+
// A trailing comma is dropped only when nothing but whitespace and
|
|
395
|
+
// comments separate it from a closing bracket.
|
|
396
|
+
let k = i + 1;
|
|
397
|
+
while (k < n) {
|
|
398
|
+
const lookahead = text[k];
|
|
399
|
+
if (lookahead === ' ' || lookahead === '\t' || lookahead === '\n' || lookahead === '\r') { k += 1; continue; }
|
|
400
|
+
if (lookahead === '/' && text[k + 1] === '/') { k += 2; while (k < n && text[k] !== '\n' && text[k] !== '\r') k += 1; continue; }
|
|
401
|
+
if (lookahead === '/' && text[k + 1] === '*') { k += 2; while (k < n && !(text[k] === '*' && text[k + 1] === '/')) k += 1; k = Math.min(k + 2, n); continue; }
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
if (text[k] === '}' || text[k] === ']') { i += 1; continue; }
|
|
405
|
+
}
|
|
406
|
+
out += ch;
|
|
407
|
+
i += 1;
|
|
408
|
+
}
|
|
409
|
+
return out;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export function backupPathCandidates(path, kind, now = new Date()) {
|
|
413
|
+
const stamp = new Date(now).toISOString().replace(/[:.]/g, '-');
|
|
414
|
+
return ['', '-2', '-3', '-4', '-5'].map(suffix => `${path}.${kind}-${stamp}${suffix}.bak`);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Backups use exclusive creation (wx) so a repeated setup can never clobber
|
|
418
|
+
// an earlier backup; identical timestamps walk a numeric suffix instead.
|
|
419
|
+
// Failing to write the backup is fatal on purpose: healing must never risk
|
|
420
|
+
// losing the only copy of the original file.
|
|
421
|
+
export async function writeConfigBackup(path, buffer, { kind, now } = {}) {
|
|
422
|
+
let lastError;
|
|
423
|
+
for (const candidate of backupPathCandidates(path, kind, now)) {
|
|
424
|
+
try {
|
|
425
|
+
await writeFile(candidate, buffer, { flag: 'wx', mode: 0o600 });
|
|
426
|
+
return candidate;
|
|
427
|
+
} catch (error) {
|
|
428
|
+
if (error?.code !== 'EEXIST') {
|
|
429
|
+
throw new Error(`Could not back up ${path} before healing (${error.message}); the original file was left untouched.`);
|
|
430
|
+
}
|
|
431
|
+
lastError = error;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
throw new Error(`Could not back up ${path}: no unique backup name was available (${lastError?.message}).`);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function shapeFailures(config) {
|
|
438
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
|
439
|
+
return [{ field: null, message: 'must contain a top-level JSON object' }];
|
|
440
|
+
}
|
|
441
|
+
const failures = [];
|
|
442
|
+
if (config.servers !== undefined && (!config.servers || typeof config.servers !== 'object' || Array.isArray(config.servers))) {
|
|
443
|
+
failures.push({ field: 'servers', message: 'has a non-object servers value' });
|
|
444
|
+
}
|
|
445
|
+
if (config.inputs !== undefined && config.inputs !== null && !Array.isArray(config.inputs)) {
|
|
446
|
+
failures.push({ field: 'inputs', message: 'has a non-array inputs value' });
|
|
447
|
+
}
|
|
448
|
+
return failures;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Healing ladder (install/setup only; strict readers such as --doctor keep
|
|
452
|
+
// rejecting): BOM/UTF-16 decode -> torn-write re-read -> JSONC recovery ->
|
|
453
|
+
// field-level repair -> full reset. Every lossy step backs up the original
|
|
454
|
+
// first; a backup failure aborts rather than risking data loss.
|
|
455
|
+
async function readConfig(path, options = {}) {
|
|
456
|
+
const { recoverInvalid = false, warnings, retryDelayMs = 250, readFileImpl = readFile } = options;
|
|
457
|
+
let buffer;
|
|
304
458
|
try {
|
|
305
|
-
|
|
459
|
+
buffer = await readFileImpl(path);
|
|
306
460
|
} catch (error) {
|
|
307
|
-
if (error?.code === 'ENOENT') return
|
|
461
|
+
if (error?.code === 'ENOENT') return freshConfig();
|
|
308
462
|
throw new Error(`MCP config ${path} could not be read: ${error.message}`);
|
|
309
463
|
}
|
|
310
|
-
let
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
464
|
+
let text = decodeJsonFileBuffer(buffer);
|
|
465
|
+
if (!text.trim()) return freshConfig();
|
|
466
|
+
const tryParse = candidate => {
|
|
467
|
+
try { return { config: JSON.parse(candidate) }; } catch (error) { return { error }; }
|
|
468
|
+
};
|
|
469
|
+
let parsed = tryParse(text);
|
|
470
|
+
if (parsed.error && retryDelayMs > 0) {
|
|
471
|
+
// A torn read — setup racing an editor's non-atomic save — looks exactly
|
|
472
|
+
// like corruption; re-read once before healing so a transient state is
|
|
473
|
+
// never backed up and replaced.
|
|
474
|
+
await sleep(retryDelayMs);
|
|
475
|
+
const second = await readFileImpl(path).catch(() => null);
|
|
476
|
+
if (second && !second.equals(buffer)) {
|
|
477
|
+
buffer = second;
|
|
478
|
+
text = decodeJsonFileBuffer(second);
|
|
479
|
+
if (!text.trim()) return freshConfig();
|
|
480
|
+
parsed = tryParse(text);
|
|
481
|
+
}
|
|
315
482
|
}
|
|
316
|
-
|
|
317
|
-
|
|
483
|
+
let loosened = false;
|
|
484
|
+
if (parsed.error && recoverInvalid) {
|
|
485
|
+
const recovered = tryParse(loosenJsonText(text));
|
|
486
|
+
if (!recovered.error) {
|
|
487
|
+
parsed = recovered;
|
|
488
|
+
loosened = true;
|
|
489
|
+
}
|
|
318
490
|
}
|
|
319
|
-
|
|
320
|
-
|
|
491
|
+
const failures = parsed.error ? [{ field: null, message: `contains invalid JSON: ${parsed.error.message}` }] : shapeFailures(parsed.config);
|
|
492
|
+
if (failures.some(failure => failure.field === null)) {
|
|
493
|
+
const detail = failures[0].message;
|
|
494
|
+
if (!recoverInvalid) throw new Error(`MCP config ${path} ${detail}`);
|
|
495
|
+
return recoverConfig(path, buffer, warnings, {
|
|
496
|
+
kind: 'invalid',
|
|
497
|
+
notes: [`was not usable as JSON (${detail.replace(/^contains invalid JSON: /, '')}) and was replaced with a fresh config`],
|
|
498
|
+
config: freshConfig()
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
const blocking = failures.find(failure => failure.field !== 'inputs');
|
|
502
|
+
if (!recoverInvalid) {
|
|
503
|
+
if (blocking) throw new Error(`MCP config ${path} ${blocking.message}`);
|
|
504
|
+
return { ...parsed.config, servers: parsed.config.servers || {} };
|
|
505
|
+
}
|
|
506
|
+
const notes = [];
|
|
507
|
+
if (loosened) notes.push('contained comments or trailing commas (accepted by VS Code, not strict JSON)');
|
|
508
|
+
const healed = { ...parsed.config };
|
|
509
|
+
for (const failure of failures) {
|
|
510
|
+
if (failure.field === 'servers') { healed.servers = {}; notes.push('had a non-object "servers" value, which was reset'); }
|
|
511
|
+
if (failure.field === 'inputs') { delete healed.inputs; notes.push('had a non-array "inputs" value, which was removed'); }
|
|
321
512
|
}
|
|
322
|
-
return { ...config, servers: config.servers || {} };
|
|
513
|
+
if (!notes.length) return { ...parsed.config, servers: parsed.config.servers || {} };
|
|
514
|
+
return recoverConfig(path, buffer, warnings, {
|
|
515
|
+
kind: loosened ? 'healed' : 'repaired',
|
|
516
|
+
notes,
|
|
517
|
+
config: { ...healed, servers: healed.servers || {} }
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async function recoverConfig(path, buffer, warnings, { kind, notes, config }) {
|
|
522
|
+
// Back up beside the real file (following symlinks) so a linked mcp.json
|
|
523
|
+
// keeps its link and the backup sits next to the actual target.
|
|
524
|
+
const backupBase = await realpath(path).catch(() => path);
|
|
525
|
+
const backup = await writeConfigBackup(backupBase, buffer, { kind });
|
|
526
|
+
warnings?.push(`The existing MCP config ${notes.join(' and ')}; the original was backed up to ${backup}.`);
|
|
527
|
+
return config;
|
|
323
528
|
}
|
|
324
529
|
|
|
325
530
|
async function writeConfig(path, config) {
|
|
326
|
-
|
|
531
|
+
// Follow symlinks so a linked mcp.json (dotfiles managers) keeps its link
|
|
532
|
+
// while the real target is replaced atomically.
|
|
533
|
+
const target = await realpath(path).catch(() => path);
|
|
534
|
+
const directory = dirname(target);
|
|
327
535
|
await mkdir(directory, { recursive: true });
|
|
328
|
-
const temporary = join(directory, `.${basename(
|
|
536
|
+
const temporary = join(directory, `.${basename(target)}.${process.pid}.${randomUUID()}.tmp`);
|
|
329
537
|
try {
|
|
330
538
|
await writeFile(temporary, `${JSON.stringify(config, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
331
|
-
await rename(temporary,
|
|
539
|
+
await rename(temporary, target);
|
|
332
540
|
} catch (error) {
|
|
333
541
|
await rm(temporary, { force: true }).catch(() => {});
|
|
334
542
|
throw new Error(`MCP config ${path} could not be written: ${error.message}`);
|
|
@@ -343,7 +551,11 @@ export async function installMcpConfig(destinationsOrOptions, options = {}) {
|
|
|
343
551
|
}
|
|
344
552
|
const env = options.env || process.env;
|
|
345
553
|
const path = options.path || await resolveMcpConfigPath(env);
|
|
346
|
-
const
|
|
554
|
+
const installWarnings = [];
|
|
555
|
+
// recoverInvalid: BOM/encoding damage, VS Code JSONC comments, torn writes
|
|
556
|
+
// racing an editor save, and corrupt configs are healed (with a backup)
|
|
557
|
+
// instead of failing the whole setup (self-healing).
|
|
558
|
+
const config = await readConfig(path, { recoverInvalid: true, warnings: installWarnings });
|
|
347
559
|
const selfVersion = await runningPackageVersion();
|
|
348
560
|
const companionVersions = selfVersion ? { 'sap-ai-dev-toolkit': selfVersion, 'hana-cloud-inspector': selfVersion } : {};
|
|
349
561
|
const generated = {
|
|
@@ -356,12 +568,18 @@ export async function installMcpConfig(destinationsOrOptions, options = {}) {
|
|
|
356
568
|
if (input?.id) generatedInputs.set(input.id, input);
|
|
357
569
|
}
|
|
358
570
|
}
|
|
359
|
-
|
|
571
|
+
let launcherCommand = options.command || null;
|
|
572
|
+
let launcherArgs = options.command ? options.args : null;
|
|
573
|
+
if (!launcherCommand && destinations.length) {
|
|
574
|
+
const resolved = await resolveLauncherInstallCommand(env);
|
|
575
|
+
launcherCommand = resolved.command;
|
|
576
|
+
launcherArgs = resolved.args;
|
|
577
|
+
}
|
|
360
578
|
if (launcherCommand) {
|
|
361
579
|
for (const entry of Object.values(generated)) {
|
|
362
580
|
if (isCompanionServer(entry)) continue;
|
|
363
581
|
entry.command = launcherCommand;
|
|
364
|
-
if (
|
|
582
|
+
if (launcherArgs?.length) entry.args = [...launcherArgs];
|
|
365
583
|
}
|
|
366
584
|
}
|
|
367
585
|
const servers = Object.create(null);
|
|
@@ -391,7 +609,7 @@ export async function installMcpConfig(destinationsOrOptions, options = {}) {
|
|
|
391
609
|
config.inputs = [...existingInputs, ...generatedInputs.values()];
|
|
392
610
|
}
|
|
393
611
|
await writeConfig(path, config);
|
|
394
|
-
return { path, servers: generated };
|
|
612
|
+
return { path, servers: generated, warnings: installWarnings };
|
|
395
613
|
}
|
|
396
614
|
|
|
397
615
|
export async function repairManagedMcpConfig(discoveredDestinations, { env = process.env, path, discoveryComplete = false, packageVersion } = {}) {
|
|
@@ -406,7 +624,7 @@ export async function repairManagedMcpConfig(discoveredDestinations, { env = pro
|
|
|
406
624
|
const config = await readConfig(configPath);
|
|
407
625
|
const managedEntries = Object.values(config.servers).filter(isManagedBasDestinationEntry).length;
|
|
408
626
|
if (!basDestinations.size) return { path: configPath, changed: false, repaired: 0, managedEntries };
|
|
409
|
-
const
|
|
627
|
+
const localLauncher = await resolveLauncherInstallCommand(env);
|
|
410
628
|
let repaired = 0;
|
|
411
629
|
for (const [serverName, entry] of Object.entries(config.servers)) {
|
|
412
630
|
if (!isManagedBasDestinationEntry(entry)) continue;
|
|
@@ -430,14 +648,16 @@ export async function repairManagedMcpConfig(discoveredDestinations, { env = pro
|
|
|
430
648
|
delete nextEnvironment.BAS_VSP_DESTINATION;
|
|
431
649
|
delete nextEnvironment.BAS_VSP_DESTINATION_SOURCE;
|
|
432
650
|
const nextEntry = { ...entry, type: 'stdio', env: nextEnvironment, BAS_EXT: 'true' };
|
|
433
|
-
if (entry
|
|
651
|
+
if (npxLaunchArgs(entry) !== null) {
|
|
434
652
|
if (packageVersion && Array.isArray(entry.args)) {
|
|
435
653
|
nextEntry.args = entry.args.map(argument => typeof argument === 'string' && /^--package=sap-ai-dev-toolkit(?:@[^/]+)?$/.test(argument)
|
|
436
654
|
? `--package=sap-ai-dev-toolkit@${packageVersion}`
|
|
437
655
|
: argument);
|
|
438
656
|
}
|
|
439
657
|
} else {
|
|
440
|
-
nextEntry.command =
|
|
658
|
+
nextEntry.command = localLauncher.command;
|
|
659
|
+
if (localLauncher.args) nextEntry.args = [...localLauncher.args];
|
|
660
|
+
else delete nextEntry.args;
|
|
441
661
|
}
|
|
442
662
|
if (expectedName === serverName) {
|
|
443
663
|
if (JSON.stringify(nextEntry) === JSON.stringify(entry)) continue;
|
|
@@ -454,6 +674,6 @@ export async function repairManagedMcpConfig(discoveredDestinations, { env = pro
|
|
|
454
674
|
return { path: configPath, changed: true, repaired, managedEntries };
|
|
455
675
|
}
|
|
456
676
|
|
|
457
|
-
export async function readMcpConfig(path) {
|
|
458
|
-
return readConfig(path);
|
|
677
|
+
export async function readMcpConfig(path, options = {}) {
|
|
678
|
+
return readConfig(path, options);
|
|
459
679
|
}
|
package/src/mcp-proxy.mjs
CHANGED
|
@@ -200,9 +200,12 @@ export function childArguments(destination, env = process.env) {
|
|
|
200
200
|
|
|
201
201
|
// Windows cannot exec a .js/.mjs file directly (no shebang support), so test
|
|
202
202
|
// fixtures written in JavaScript are launched through the current Node binary.
|
|
203
|
-
// The production VSP binary is a native executable and is unaffected
|
|
203
|
+
// The production VSP binary is a native executable and is unaffected; a
|
|
204
|
+
// Windows .cmd/.bat override needs cmd /c because Node refuses to spawn
|
|
205
|
+
// batch files directly (EINVAL since the CVE-2024-27980 hardening).
|
|
204
206
|
function spawnCommand(binary) {
|
|
205
207
|
if (/\.(?:mjs|cjs|js)$/i.test(binary)) return [process.execPath, binary];
|
|
208
|
+
if (/\.(?:cmd|bat)$/i.test(binary) && process.platform === 'win32') return ['cmd', '/c', binary];
|
|
206
209
|
return [binary];
|
|
207
210
|
}
|
|
208
211
|
|
package/src/setup.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { createInterface } from 'node:readline/promises';
|
|
|
2
2
|
import { stdin, stdout } from 'node:process';
|
|
3
3
|
import { platform } from 'node:os';
|
|
4
4
|
import { isIP } from 'node:net';
|
|
5
|
-
import { checkboxPrompt, colorText, formatStatus, selectPrompt, textPrompt } from './terminal-ui.mjs';
|
|
5
|
+
import { checkboxPrompt, colorText, formatStatus, selectPrompt, startProgress, textPrompt } from './terminal-ui.mjs';
|
|
6
6
|
import { discoverDestinations, remediation } from './bas-discovery.mjs';
|
|
7
7
|
import { defaultAdtUrl, discoverSapGuiSystems } from './local-sap-gui.mjs';
|
|
8
8
|
import { discoverCloudFoundryDestinations, getCloudFoundryTarget, deleteManagedCloudFoundryServiceKeys, findOrphanedCloudFoundryServiceKeys } from './cf-destination.mjs';
|
|
@@ -237,15 +237,21 @@ export async function runSetup({
|
|
|
237
237
|
return { skipped: true, reason: 'non-tty' };
|
|
238
238
|
}
|
|
239
239
|
print(output, '');
|
|
240
|
-
print(output, formatStatus(
|
|
240
|
+
if (!isBas) print(output, formatStatus('Looking for local SAP GUI system configuration.', 'progress', output, 'Setup'));
|
|
241
241
|
|
|
242
242
|
let destinations = [];
|
|
243
243
|
if (isBas) {
|
|
244
244
|
let basDestinations;
|
|
245
|
+
// BAS discovery probes every destination's ADT endpoint (up to a 5s
|
|
246
|
+
// timeout each); the animated progress line keeps the terminal visibly
|
|
247
|
+
// alive from the first moment instead of looking stuck.
|
|
248
|
+
const stopDiscoveryProgress = startProgress('Contacting BAS to discover destinations', { output, env, label: 'Setup' });
|
|
245
249
|
try {
|
|
246
250
|
basDestinations = await discover({ env: { ...env, SAP_AI_DEV_TOOLKIT_DESTINATION: '' } });
|
|
247
251
|
} catch (error) {
|
|
248
252
|
throw new Error(`BAS discovery failed: ${error.message}`);
|
|
253
|
+
} finally {
|
|
254
|
+
stopDiscoveryProgress();
|
|
249
255
|
}
|
|
250
256
|
destinations = basDestinations.map(safeBasDestination);
|
|
251
257
|
} else {
|
|
@@ -525,6 +531,7 @@ export async function runSetup({
|
|
|
525
531
|
const configuredCount = selected.length + sapDevelopmentServers.length;
|
|
526
532
|
print(output, formatStatus(`Configured ${configuredCount} MCP server${configuredCount === 1 ? '' : 's'}${location}.`, 'success', output, isBas ? 'BAS setup' : 'Local setup'));
|
|
527
533
|
printConnectionInstructions(output, result);
|
|
534
|
+
warnings.push(...(Array.isArray(result?.warnings) ? result.warnings.filter(message => typeof message === 'string') : []));
|
|
528
535
|
let cleanupWarnings = [];
|
|
529
536
|
const finalPath = result?.path || configPath;
|
|
530
537
|
if (isBas) {
|
package/src/terminal-ui.mjs
CHANGED
|
@@ -401,3 +401,43 @@ export async function textPrompt({ message, secret = false, required = true, pla
|
|
|
401
401
|
}
|
|
402
402
|
});
|
|
403
403
|
}
|
|
404
|
+
|
|
405
|
+
// Animated progress line for long-running steps (destination discovery,
|
|
406
|
+
// ADT probes, VSP binary provisioning). TTY outputs get spinner frames on an
|
|
407
|
+
// unref'd timer that starts after a short delay, so steps that finish quickly
|
|
408
|
+
// never flash anything. Non-TTY outputs (pipes, MCP hosts, --json callers,
|
|
409
|
+
// CI) stay completely silent so machine consumers never see progress noise.
|
|
410
|
+
// Returns stop(); on a TTY, stop() clears the line so it never pollutes
|
|
411
|
+
// scrollback. stop.update(message) retargets the label while running (for
|
|
412
|
+
// example per destination during --doctor).
|
|
413
|
+
export function startProgress(initialMessage, { output = process.stdout, env = process.env, label = 'sap-ai-dev-toolkit' } = {}) {
|
|
414
|
+
const noop = Object.assign(() => {}, { update: () => {} });
|
|
415
|
+
if (!output || typeof output.write !== 'function') return noop;
|
|
416
|
+
const animationDisabled = env.TERM === 'dumb' || env.CI || env.SAP_AI_DEV_TOOLKIT_DISABLE_SCAN_ANIMATION === 'true';
|
|
417
|
+
if (!output.isTTY || !Number.isFinite(output.columns) || animationDisabled) return noop;
|
|
418
|
+
let message = String(initialMessage);
|
|
419
|
+
let index = 0;
|
|
420
|
+
let active = false;
|
|
421
|
+
let interval;
|
|
422
|
+
const render = () => {
|
|
423
|
+
active = true;
|
|
424
|
+
index += 1;
|
|
425
|
+
const columns = Math.max(20, output.columns || 80);
|
|
426
|
+
const dots = `.${'.'.repeat(index % 3)}${' '.repeat(2 - index % 3)}`;
|
|
427
|
+
const line = `${label}: ${message} ${dots}`.slice(0, Math.max(1, columns - 1));
|
|
428
|
+
output.write(`\r\u001b[2K${line}`);
|
|
429
|
+
};
|
|
430
|
+
const delay = setTimeout(() => {
|
|
431
|
+
render();
|
|
432
|
+
interval = setInterval(render, 120);
|
|
433
|
+
if (typeof interval.unref === 'function') interval.unref();
|
|
434
|
+
}, 150);
|
|
435
|
+
if (typeof delay.unref === 'function') delay.unref();
|
|
436
|
+
const stop = () => {
|
|
437
|
+
clearTimeout(delay);
|
|
438
|
+
if (interval) clearInterval(interval);
|
|
439
|
+
if (active) output.write('\r\u001b[2K');
|
|
440
|
+
};
|
|
441
|
+
stop.update = next => { message = String(next); };
|
|
442
|
+
return stop;
|
|
443
|
+
}
|