@mcp-abap-adt/connection 6.0.0 → 6.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,11 +7,119 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [6.0.1] - 2026-08-25
11
+
12
+ ### Removed
13
+
14
+ - **`express` is no longer a dependency.** It was a runtime dependency for one
15
+ thing: the localhost server in `bin/sap-abap-auth.js` that catches the OAuth2
16
+ redirect. The library never touched it, and that file already imported
17
+ `node:http`. Rewritten on `node:http`, which sheds 5.6MB of `node_modules` and
18
+ the transitive tree the Dependabot bumps kept arriving for — `qs`,
19
+ `body-parser` and `follow-redirects` were all express's.
20
+
21
+ Two things express was doing implicitly are now stated: a request is the
22
+ redirect only when it is a `GET` for `/callback`, so a browser fetching
23
+ `/favicon.ico` no longer reaches the handler waiting for an authorization
24
+ code; and the success page declares `text/html`, which `res.send()` used to
25
+ infer. Exercised as a server: `200` with the page, `400` without a code, `404`
26
+ for anything else including a `POST`.
27
+
28
+ Runtime dependencies are now `@mcp-abap-adt/interfaces`, `axios`, `commander`
29
+ and `open`. The end-to-end flow was measured against the BTP trial — see
30
+ *Verified* below.
31
+
32
+ ### Changed
33
+
34
+ - **The browser wait is the caller's to set.** `bin/sap-abap-auth.js` waited a
35
+ fixed five minutes for the redirect, and a real BTP login does not fit in it —
36
+ the identity provider, a password manager and whatever second factor the
37
+ tenant asks for all happen before the redirect is issued. Measured twice, and
38
+ both times the window closed first; the browser then had nowhere to redirect
39
+ to, which reads as the authentication having failed rather than as a timeout.
40
+
41
+ `--timeout <minutes>`, or `SAP_AUTH_TIMEOUT_MS` for a script that would rather
42
+ not pass an argument, with the flag winning when both are given. Anything that
43
+ is not a positive number is refused by name rather than silently becoming a
44
+ default. **The default is now fifteen minutes**, which covered the login this
45
+ was measured against with room to spare and still bounds an abandoned run.
46
+
47
+ ### Verified
48
+
49
+ - **The whole chain, end to end**: `bin/sap-abap-auth.js auth` → browser
50
+ authorization against the tenant's UAA → redirect to `localhost:3001/callback`
51
+ → code exchanged for a token → `.env` written with `SAP_JWT_TOKEN`,
52
+ `SAP_REFRESH_TOKEN` and the UAA fields.
53
+
54
+ - Each seam the express removal touched, measured on the real handler rather
55
+ than a stand-in: `GET /favicon.ico` answers `404` and does not reach the
56
+ handler waiting for a code — routing express used to do; `GET /callback` with
57
+ no code answers `400`; the success page comes back
58
+ `200 text/html; charset=utf-8`, the Content-Type `res.send()` used to infer
59
+ and `node:http` will not.
60
+
61
+ - **The issued token then drove this library** against the trial:
62
+ `AdtCloudConnector` with `CloudHttpTransport` connected, took a session
63
+ (`SAP_SESSIONID_TRL_100`), answered a `GET /sap/bc/adt/discovery` with `200`
64
+ and 444826 bytes of XML, and `disconnect()` left `isConnected()` false.
65
+
66
+ ### Fixed
67
+
68
+ - **The live RFC suite skips a wire the machine did not install.** It asked for
69
+ four environment variables and nothing else, so a checkout with an env file
70
+ and no SAP NW RFC SDK ran the suite anyway and went eleven tests red, all on
71
+ `@mcp-abap-adt/sap-rfc-lite is not available`. The dependency is `optional` —
72
+ `npm ci` leaves it out WITHOUT failing, which is what optional means — so
73
+ nothing warns and the next run is red for a reason unrelated to the change
74
+ under test.
75
+
76
+ Nothing here is configured at build time the way a C package is: the installed
77
+ optional dependency IS the choice of wire, made at install and readable only
78
+ at runtime. `canRun()` now reads it with `require.resolve`, asking whether the
79
+ wire could be taken rather than loading a native addon to find out. Measured
80
+ in both states: SDK hidden — 11 skipped, 2 passed, green; SDK present — 13
81
+ passed.
82
+
83
+ - `RfcTransport` says why it ignores `request.timeout`. No behaviour change: the
84
+ file contained no occurrence of the word, and that silence read as an
85
+ oversight. It is deliberate — the SDK exposes no cancel, its per-call
86
+ `timeout` is parsed into a commented-out branch, and an abandoned call still
87
+ holds the conversation, so the next one would queue behind a call nobody is
88
+ waiting for. A deadline that reports failure while the wire stays busy is
89
+ worse than none, because the error reads as "safe to retry" when it is not.
90
+ The server bounds the call at `rdisp/max_wprun_time`.
91
+
10
92
  ## [6.0.0] - 2026-08-24
11
93
 
12
94
  The wire owns what is the wire's, and the factory and per-credential classes are
13
95
  gone. See [Migration to 6.0](./docs/MIGRATION-6.0.md).
14
96
 
97
+ ### Added — tooling
98
+
99
+ - **CI.** Until this release the only workflow ran on a tag, and `Release` went
100
+ from build straight to `npm pack` — so nothing ran the gate before a merge,
101
+ and the artifact a tag published had been tested nowhere but on a laptop.
102
+ `ci.yml` runs `lint:check`, `build`, `jest --no-cache`, `check:docs` and
103
+ `check:pack` on push and pull request, across ubuntu and windows on Node 18
104
+ and 20. Windows is in the matrix because npm is `npm.cmd` there and Node will
105
+ not execute a `.cmd` without a shell. `release.yml` runs the same three checks
106
+ between build and pack, so a tag is not a route around them.
107
+
108
+ - **`check:pack`** — inspects `npm pack --dry-run --json` and refuses a tarball
109
+ carrying a test file, a `.tsbuildinfo`, a `.ts` source, an env file or an
110
+ npmrc. Both defects it was written for had already shipped once:
111
+ `dist/__tests__/` and 120KB of compiler bookkeeping, neither visible from
112
+ `files`, because it is the compiler that decides what `dist` holds.
113
+
114
+ - **`typecheck:scripts`** — `scripts/*.ts` were checked by nothing, which is how
115
+ three live verification scripts came to sit on a constructor signature two
116
+ releases old.
117
+
118
+ ### Changed — packaging
119
+
120
+ - 111 files / 182KB → 95 / 147KB. Test helpers and the compiler's incremental
121
+ state no longer ship.
122
+
15
123
  ### Added
16
124
 
17
125
  - **The transport axis is complete and public.** `IAdtTransport` now covers
@@ -1252,7 +1360,8 @@ const connection = createAbapConnection(config, logger);
1252
1360
  - JWT token refresh now properly handles connection errors (401/403 during initial connect)
1253
1361
  - Permission errors (403 with "ExceptionResourceNoAccess") no longer trigger JWT refresh loops
1254
1362
  - Proper separation: base class handles HTTP/session, concrete classes handle auth-specific errors
1255
- [Unreleased]: https://github.com/fr0ster/mcp-abap-connection/compare/v6.0.0...HEAD
1363
+ [Unreleased]: https://github.com/fr0ster/mcp-abap-connection/compare/v6.0.1...HEAD
1364
+ [6.0.1]: https://github.com/fr0ster/mcp-abap-connection/compare/v6.0.0...v6.0.1
1256
1365
  [6.0.0]: https://github.com/fr0ster/mcp-abap-connection/compare/v5.0.0...v6.0.0
1257
1366
  [5.0.0]: https://github.com/fr0ster/mcp-abap-connection/compare/v4.0.0...v5.0.0
1258
1367
  [4.0.0]: https://github.com/fr0ster/mcp-abap-connection/compare/v3.0.0...v4.0.0
@@ -3,7 +3,6 @@ const fs = require("fs");
3
3
  const path = require("path");
4
4
  const axios = require("axios");
5
5
  const { program } = require("commander");
6
- const express = require("express");
7
6
  const open = require("open").default;
8
7
  const http = require("http");
9
8
 
@@ -262,6 +261,57 @@ function getJwtAuthorizationUrl(serviceKey, port = 3001) {
262
261
  )}&response_type=code&redirect_uri=${encodeURIComponent(redirectUri)}`;
263
262
  }
264
263
 
264
+ /**
265
+ * How long to wait for the browser to come back.
266
+ *
267
+ * Five minutes was the fixed value, and a real BTP login does not fit in it:
268
+ * the identity provider, a password manager and whatever second factor the
269
+ * tenant asks for all happen before the redirect is issued. Measured twice —
270
+ * both times the window closed first, and the browser then had nowhere to
271
+ * redirect to, which reads to the user as the authentication having failed.
272
+ *
273
+ * So it is a decision the caller makes: `--timeout` in minutes, or
274
+ * `SAP_AUTH_TIMEOUT_MS` for a script that would rather not pass an argument.
275
+ * The default is fifteen, which covered the login this was measured against
276
+ * with room to spare and still bounds a run that has been abandoned.
277
+ */
278
+ const DEFAULT_AUTH_TIMEOUT_MS = 15 * 60 * 1000;
279
+
280
+ function resolveAuthTimeout(minutes) {
281
+ if (minutes !== undefined) {
282
+ const value = Number(minutes);
283
+ if (!Number.isFinite(value) || value <= 0) {
284
+ throw new Error(
285
+ `--timeout must be a positive number of minutes, got: ${minutes}`
286
+ );
287
+ }
288
+ return value * 60 * 1000;
289
+ }
290
+ const fromEnv = process.env.SAP_AUTH_TIMEOUT_MS;
291
+ if (fromEnv !== undefined && fromEnv !== "") {
292
+ const value = Number(fromEnv);
293
+ if (!Number.isFinite(value) || value <= 0) {
294
+ throw new Error(
295
+ `SAP_AUTH_TIMEOUT_MS must be a positive number of milliseconds, got: ${fromEnv}`
296
+ );
297
+ }
298
+ return value;
299
+ }
300
+ return DEFAULT_AUTH_TIMEOUT_MS;
301
+ }
302
+
303
+ /**
304
+ * One reply, with the header express used to set on our behalf.
305
+ *
306
+ * `res.send()` chose a Content-Type from what it was handed; `node:http` sends
307
+ * exactly what it is told, so the type is stated here. Without it a browser is
308
+ * free to render the success page as plain text.
309
+ */
310
+ function send(res, status, contentType, body) {
311
+ res.writeHead(status, { "Content-Type": contentType });
312
+ res.end(body);
313
+ }
314
+
265
315
  /**
266
316
  * Starts a local server to intercept the authentication response
267
317
  * @param {Object} serviceKey SAP BTP service key object
@@ -269,26 +319,42 @@ function getJwtAuthorizationUrl(serviceKey, port = 3001) {
269
319
  * @param {string} flow Flow type: jwt (OAuth2)
270
320
  * @returns {Promise<{accessToken: string, refreshToken?: string}>} Promise that resolves to tokens
271
321
  */
272
- async function startAuthServer(serviceKey, browser = undefined, flow = "jwt") {
322
+ async function startAuthServer(
323
+ serviceKey,
324
+ browser = undefined,
325
+ flow = "jwt",
326
+ timeoutMs = DEFAULT_AUTH_TIMEOUT_MS
327
+ ) {
273
328
  return new Promise((resolve, reject) => {
274
- const app = express();
275
- const server = http.createServer(app);
276
329
  const PORT = 3001;
277
330
  let serverInstance = null;
278
331
 
279
332
  // Choose the authorization URL
280
333
  const authorizationUrl = getJwtAuthorizationUrl(serviceKey, PORT);
281
334
 
282
- // JWT OAuth2 flow (get code, exchange for token)
283
- app.get("/callback", async (req, res) => {
335
+ // JWT OAuth2 flow (get code, exchange for token). One route, and the
336
+ // routing express did is two comparisons: a browser fetching /favicon.ico
337
+ // must not be mistaken for the redirect we are waiting for.
338
+ const server = http.createServer(async (req, res) => {
339
+ const url = new URL(req.url, `http://localhost:${PORT}`);
340
+ if (req.method !== "GET" || url.pathname !== "/callback") {
341
+ send(res, 404, "text/plain; charset=utf-8", "Not Found");
342
+ return;
343
+ }
344
+
284
345
  try {
285
- const { code } = req.query;
346
+ const code = url.searchParams.get("code");
286
347
  if (!code) {
287
- res.status(400).send("Error: Authorization code missing");
348
+ send(
349
+ res,
350
+ 400,
351
+ "text/plain; charset=utf-8",
352
+ "Error: Authorization code missing"
353
+ );
288
354
  return reject(new Error("Authorization code missing"));
289
355
  }
290
356
  console.log("Authorization code received");
291
- res.send(`<!DOCTYPE html>
357
+ send(res, 200, "text/html; charset=utf-8", `<!DOCTYPE html>
292
358
  <html lang="en">
293
359
  <head>
294
360
  <meta charset="UTF-8">
@@ -363,7 +429,12 @@ async function startAuthServer(serviceKey, browser = undefined, flow = "jwt") {
363
429
  }
364
430
  } catch (error) {
365
431
  console.error("Error handling callback:", error);
366
- res.status(500).send("Error processing authentication");
432
+ send(
433
+ res,
434
+ 500,
435
+ "text/plain; charset=utf-8",
436
+ "Error processing authentication"
437
+ );
367
438
  reject(error);
368
439
  }
369
440
  });
@@ -395,9 +466,17 @@ async function startAuthServer(serviceKey, browser = undefined, flow = "jwt") {
395
466
  setTimeout(() => {
396
467
  if (serverInstance) {
397
468
  serverInstance.close();
398
- reject(new Error("Authentication timeout. Process aborted."));
469
+ reject(
470
+ new Error(
471
+ `Authentication timeout after ${
472
+ timeoutMs >= 60000
473
+ ? `${Math.round(timeoutMs / 60000)} min`
474
+ : `${Math.round(timeoutMs / 1000)}s`
475
+ }. Process aborted. Raise it with --timeout <minutes> or SAP_AUTH_TIMEOUT_MS if the login needs longer.`
476
+ )
477
+ );
399
478
  }
400
- }, 5 * 60 * 1000);
479
+ }, timeoutMs);
401
480
  });
402
481
  }
403
482
 
@@ -540,6 +619,10 @@ async function main() {
540
619
  "-f, --force",
541
620
  "Force browser authentication even if valid tokens exist in .env"
542
621
  )
622
+ .option(
623
+ "-t, --timeout <minutes>",
624
+ "How long to wait for the browser redirect, in minutes (default: 15, or SAP_AUTH_TIMEOUT_MS in ms)"
625
+ )
543
626
  .helpOption("-h, --help", "Show help for the auth command")
544
627
  .action(async (options) => {
545
628
  try {
@@ -594,7 +677,12 @@ async function main() {
594
677
  // Fallback to browser authentication if refresh failed or was skipped
595
678
  if (!tokens) {
596
679
  console.log("🌐 Starting browser authentication...");
597
- tokens = await startAuthServer(serviceKey, options.browser, "jwt");
680
+ tokens = await startAuthServer(
681
+ serviceKey,
682
+ options.browser,
683
+ "jwt",
684
+ resolveAuthTimeout(options.timeout)
685
+ );
598
686
  if (!tokens || !tokens.accessToken) {
599
687
  console.error("JWT token was not obtained. Authentication failed.");
600
688
  process.exit(1);
@@ -1 +1 @@
1
- {"version":3,"file":"RfcTransport.d.ts","sourceRoot":"","sources":["../../src/connection/RfcTransport.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAGH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAC5C,OAAO,KAAK,EACV,oBAAoB,EAEpB,oBAAoB,EACpB,qBAAqB,EACrB,gBAAgB,EACjB,MAAM,oBAAoB,CAAC;AAE5B,sFAAsF;AACtF,MAAM,WAAW,gBAAgB;IAC/B,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACtB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvB,IAAI,CACF,EAAE,EAAE,MAAM,EACV,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC9B,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC,CAAC;IAChC,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;CACzB;AAmCD,qBAAa,YAAa,YAAW,gBAAgB;IA+EjD,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,MAAM;IA/EzB,QAAQ,CAAC,IAAI,SAAS;IAEtB,6EAA6E;IAC7E,QAAQ,CAAC,MAAM,EAAG,QAAQ,CAAU;IAEpC,OAAO,CAAC,YAAY,CAAiC;IACrD,kEAAkE;IAClE,OAAO,CAAC,cAAc,CAAM;IAE5B;;;;;;OAMG;IACH,MAAM,IAAI,IAAI;IAEd,2CAA2C;IAC3C,OAAO,IAAI,IAAI;IAIf;;;;;OAKG;IACG,SAAS,CAAC,QAAQ,EAAE,oBAAoB,GAAG,OAAO,CAAC,IAAI,CAAC;IAE9D,+BAA+B;IAC/B,SAAS,IAAI,IAAI;IAIjB;;;OAGG;IACH,cAAc,IAAI,IAAI;IAEtB;;;OAGG;IACH,6EAA6E;IAC7E,kBAAkB,IAAI,OAAO;IAI7B,kBAAkB,IAAI,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC;IAQzC,2EAA2E;IAC3E,eAAe,IAAI,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC;IAIzC;;;;OAIG;IACH,aAAa,IAAI,IAAI;IAErB;;;;;OAKG;gBAEgB,OAAO,EAAE,MAAM,gBAAgB,EAC/B,MAAM,GAAE,OAAO,GAAG,IAAW;IAG1C,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAiB3B,0DAA0D;IACpD,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAatB,IAAI,CAAC,OAAO,EAAE,oBAAoB,GAAG,OAAO,CAAC,qBAAqB,CAAC;CA8H1E"}
1
+ {"version":3,"file":"RfcTransport.d.ts","sourceRoot":"","sources":["../../src/connection/RfcTransport.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAGH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAC5C,OAAO,KAAK,EACV,oBAAoB,EAEpB,oBAAoB,EACpB,qBAAqB,EACrB,gBAAgB,EACjB,MAAM,oBAAoB,CAAC;AAE5B,sFAAsF;AACtF,MAAM,WAAW,gBAAgB;IAC/B,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACtB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvB,IAAI,CACF,EAAE,EAAE,MAAM,EACV,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC9B,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC,CAAC;IAChC,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;CACzB;AAmCD,qBAAa,YAAa,YAAW,gBAAgB;IA+EjD,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,MAAM;IA/EzB,QAAQ,CAAC,IAAI,SAAS;IAEtB,6EAA6E;IAC7E,QAAQ,CAAC,MAAM,EAAG,QAAQ,CAAU;IAEpC,OAAO,CAAC,YAAY,CAAiC;IACrD,kEAAkE;IAClE,OAAO,CAAC,cAAc,CAAM;IAE5B;;;;;;OAMG;IACH,MAAM,IAAI,IAAI;IAEd,2CAA2C;IAC3C,OAAO,IAAI,IAAI;IAIf;;;;;OAKG;IACG,SAAS,CAAC,QAAQ,EAAE,oBAAoB,GAAG,OAAO,CAAC,IAAI,CAAC;IAE9D,+BAA+B;IAC/B,SAAS,IAAI,IAAI;IAIjB;;;OAGG;IACH,cAAc,IAAI,IAAI;IAEtB;;;OAGG;IACH,6EAA6E;IAC7E,kBAAkB,IAAI,OAAO;IAI7B,kBAAkB,IAAI,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC;IAQzC,2EAA2E;IAC3E,eAAe,IAAI,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC;IAIzC;;;;OAIG;IACH,aAAa,IAAI,IAAI;IAErB;;;;;OAKG;gBAEgB,OAAO,EAAE,MAAM,gBAAgB,EAC/B,MAAM,GAAE,OAAO,GAAG,IAAW;IAG1C,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAiB3B,0DAA0D;IACpD,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAatB,IAAI,CAAC,OAAO,EAAE,oBAAoB,GAAG,OAAO,CAAC,qBAAqB,CAAC;CA4I1E"}
@@ -187,6 +187,20 @@ class RfcTransport {
187
187
  });
188
188
  }
189
189
  this.logger?.debug(`RFC → ${method} ${uri}`);
190
+ // `request.timeout` is deliberately not read, and the absence of the word
191
+ // here is what made that look like an oversight (#42).
192
+ //
193
+ // There is nothing to enforce it with: the SDK exposes no cancel, and an
194
+ // abandoned call still holds the conversation — the next one would queue
195
+ // behind a call nobody is waiting for. A deadline that reports failure
196
+ // while the wire stays busy is worse than none, because the error reads as
197
+ // "safe to retry" when it is not.
198
+ //
199
+ // Nor is one needed. What bounds this call is the server: a dialog step
200
+ // ends at `rdisp/max_wprun_time`. And a call that cannot be torn down
201
+ // mid-flight is precisely what the connection emulates over HTTP, where
202
+ // a critical section raises the caller's timeout to a ten-minute ceiling so
203
+ // an abort cannot orphan a lock handle. Here that comes free.
190
204
  let raw;
191
205
  try {
192
206
  raw = await this.conversation.call('SADT_REST_RFC_ENDPOINT', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mcp-abap-adt/connection",
3
- "version": "6.0.0",
3
+ "version": "6.0.1",
4
4
  "description": "ABAP connection layer for MCP ABAP ADT server",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -55,7 +55,6 @@
55
55
  "@mcp-abap-adt/interfaces": "^21.0.0",
56
56
  "axios": "^1.16.0",
57
57
  "commander": "^14.0.3",
58
- "express": "^5.1.0",
59
58
  "open": "^11.0.0"
60
59
  },
61
60
  "optionalDependencies": {