guniweb-sap-mcp 0.2.1 → 0.3.0

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.
Files changed (3) hide show
  1. package/README.md +139 -8
  2. package/dist/index.js +110 -29
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -36,7 +36,7 @@ graph LR
36
36
  ### Tool Visibility (v1.5)
37
37
  - **3-Tier Tool Registry** -- Core / OData / IDoc tiers with on-demand activation
38
38
  - **`sap_enable_tools`** -- LLMs activate additional tool tiers at runtime
39
- - **`--read-only` Mode** -- Hide all write/destructive tools
39
+ - **Read-only by default (since 0.2.2)** -- write tools require `--allow-write`
40
40
  - **IDoc Auto-Disable** -- IDoc tools hidden when no IDoc config present
41
41
  - **MCP Annotations** -- readOnlyHint, destructiveHint, idempotentHint on all 17 tools
42
42
 
@@ -67,7 +67,7 @@ graph TB
67
67
  - **Three Transport Modes** -- stdio, Streamable HTTP, SSE
68
68
  - **API Key Protection** -- Secure HTTP transport with Bearer token
69
69
  - **Docker Ready** -- Docker Compose for n8n + SAP MCP sidecar
70
- - **918 Tests** -- Unit, integration, E2E with CI/CD via GitHub Actions
70
+ - **1267 Tests** -- Unit, integration, E2E with CI/CD via GitHub Actions
71
71
 
72
72
  ## Quick Start
73
73
 
@@ -84,7 +84,9 @@ export SAP_BASE_URL=https://your-sap-system.example.com
84
84
  export SAP_AUTH_TYPE=basic
85
85
  export SAP_USERNAME=your-user
86
86
  export SAP_PASSWORD=your-password
87
- export SAP_CLIENT=100
87
+ export SAP_CLIENT=100 # do not omit: without it a client-dependent Gateway
88
+ # answers 401 "Anmeldung fehlgeschlagen", which looks
89
+ # like a wrong password but is not one
88
90
  ```
89
91
 
90
92
  ### 3. Run
@@ -98,9 +100,138 @@ guniweb-sap-mcp --transport http --port 8808
98
100
 
99
101
  # With tool tier control
100
102
  guniweb-sap-mcp --transport http --port 8808 --tiers core,odata
101
- guniweb-sap-mcp --transport http --port 8808 --read-only
103
+ guniweb-sap-mcp --transport http --port 8808 --allow-write # writes are OFF by default
104
+
105
+ # Production: authenticate the MCP endpoint AND the inbound IDoc webhook
106
+ guniweb-sap-mcp --transport http --port 8808 --api-key <mcp-key> --webhook-secret <idoc-secret>
107
+ ```
108
+
109
+ #### Production hardening
110
+
111
+ | Variable / Flag | Default | Description |
112
+ |-------------------------------|----------------|-------------------------------------------------------------------------------------------------|
113
+ | `--tool-timeout` / `SAP_MCP_TOOL_TIMEOUT` | `30` (seconds) | Time budget for a **whole tool call**, however many SAP requests it makes. On expiry the client gets a valid MCP response with `truncated: true` and a plain-language reason — never a hanging connection. Paginated reads return the pages fetched so far. `0` disables the budget (logged as a warning at startup) |
114
+ | `SAP_HTTP_TIMEOUT_MS` | `30000` | Timeout for a **single** outbound SAP request (ms). Hanging gateways fail fast with a structured `TIMEOUT` error instead of stalling the n8n agent. Capped by whatever is left of the tool budget above |
115
+ | `--cache-dir` / `SAP_CACHE_DIR` | _(unset = in-memory only)_ | Persist the catalog and metadata caches to disk so a restart does not refetch them. Without it every restart re-reads the full catalog — on a system with 1222 services that is the cold start that breaks client timeouts. n8n evicts its own client entry on a transport error, so restarts are not rare |
116
+ | `SAP_CA_CERT` | _(unset)_ | Path to the root CA that signs your SAP certificate — the documented way past a corporate PKI. Mirrored into `NODE_EXTRA_CA_CERTS`. Prefer this over `SAP_TLS_VERIFY=false`, which disables the check rather than solving it |
117
+ | `--concurrency` / `SAP_CONCURRENCY` | `5` | Upper bound on simultaneous SAP requests where several are unavoidable |
118
+ | `--allow-write` / `SAP_MCP_ALLOW_WRITE` | _(unset = read-only)_ | **Since 0.2.2 the server is read-only by default.** `sap_create`, `sap_update`, `sap_delete`, `sap_function` and `sap_idoc_send` are not registered at all until you set this — a tool an agent cannot see is a tool it cannot call by accident. `--read-only` is still accepted and now simply describes the default |
119
+ | `SAP_JWT_ISSUER` | _(unset = no verification)_ | Issuer whose JWKS is used to verify incoming user tokens. Without it tokens are only decoded, and the resulting identity is marked `verified: false` — safe to forward to SAP (SAP verifies itself), but not safe to base a local decision on. With it, a token failing verification is rejected with `401` rather than silently downgraded to the technical user |
120
+ | `SAP_WEBHOOK_SECRET` / `--webhook-secret` | _(unset = open + startup warning)_ | Shared secret for the inbound IDoc webhook. Accepts `Authorization: Bearer <secret>` or HTTP Basic auth whose **password** equals the secret (matches SAP SM59 HTTP destinations) |
121
+ | `SAP_WEBHOOK_HOST` / `--webhook-host` | all interfaces | Bind address of the standalone IDoc webhook (stdio mode). Set `127.0.0.1` behind a reverse proxy |
122
+ | `SAP_WEBHOOK_MAX_BODY_BYTES` | `10485760` (10 MB) | Reject larger IDoc payloads with `413` before they are buffered in memory |
123
+
124
+ In HTTP transport mode the `/idoc` route is mounted on the MCP port: with `--webhook-secret` it authenticates independently of `--api-key` (so SAP's Basic auth works while n8n uses the Bearer key); without it, it falls under `--api-key` if set — and is **open** if neither is configured (the server logs a warning at startup).
125
+
126
+ Both timeouts must stay **below the smallest client limit** in your setup. Measured against a production S/4HANA behind Cloudflare: Cloudflare aborts at 100 s (`524`), n8n's MCP client transport at 300 s. A budget above the smallest limit never takes effect, because the client gives up first.
127
+
128
+ #### Tracing a slow tool call
129
+
130
+ Every tool call logs one line on completion, carrying a correlation id that also appears on each SAP request it triggered:
131
+
132
+ ```json
133
+ {"correlationId":"a3f19c8b2d04","tool":"sap_discover_services","outcome":"ok","durationMs":842,"sapCalls":1,"resultBytes":18422}
102
134
  ```
103
135
 
136
+ `grep a3f19c8b2d04` then shows the full story of that one call — including how many SAP requests it produced. `outcome` is one of `ok`, `error`, `timeout` (budget expired) or `aborted` (client disconnected).
137
+
138
+ **`level: 50` means something is actually wrong.** Expected non-2xx responses — the `404`s from the catalog path probing and from the V4 catalog attempt on systems that have none — are logged at `info` with `"expected": true`, not at `error`. Measured on a production system: over 96 hours of runtime that false alarm was the *only* error-level entry in the log. Alerting on `level: 50` is therefore meaningful; before, it produced nothing but noise.
139
+
140
+ #### When something breaks
141
+
142
+ `test-connection` walks the whole chain and reports the **first** failing stage — DNS, TLS (including the certificate chain), authentication, client (Mandant), catalog. Stages after the failure are reported as `skipped`, never as passed. The same five stages are logged once at startup, so the container log shows the state of the connection without calling anything.
143
+
144
+ Every error response carries a classification, the SAP error code where one exists, a plain-language message and the next action — no tool answers with `"Unknown error"`. Three cases that used to cost a separate diagnosis each:
145
+
146
+ | What SAP returns | What the server tells you |
147
+ |---|---|
148
+ | `403` with `/IWFND/MED/170` | The service is not registered — activate it in `/IWFND/MAINT_SERVICE`. **Not** an authorization problem, despite the 403 |
149
+ | `401` with an HTML logon page | Is `sap-client` set? Without it a client-dependent Gateway rejects the logon in a way that looks like a wrong password |
150
+ | `UNABLE_TO_GET_ISSUER_CERT_LOCALLY` | The certificate chain is incomplete; supply the root CA via `SAP_CA_CERT`. The message names the issuing CA |
151
+
152
+ If `SAP_TLS_VERIFY=false` is in effect, **every tool result** carries a warning about it — not just the startup log. A disabled check that nobody sees after the first day tends to stay for years; on the production system that prompted this work it was set, and nobody noticed.
153
+
154
+ #### Streamable HTTP is stateless
155
+
156
+ `--transport http` serves MCP in **stateless** mode: every request is self-contained and the server neither issues nor expects an `mcp-session-id` header. There is no server-side session to resume, so a client may reconnect at any time without losing state.
157
+
158
+ Clients that *require* a session id — they read `mcp-session-id` from the initialize response and send it back on subsequent requests — will fail against this server. The n8n MCP Client, Claude Desktop, and the MCP Inspector all work without it.
159
+
160
+ The legacy `--transport sse` mode is different: it keeps a per-connection session and expects a `?sessionId=` query parameter on `/messages`. Prefer `http` for new setups.
161
+
162
+ #### Several SAP systems: `destinations.json`
163
+
164
+ The `SAP_*` variables describe **one** SAP system. To run one server against several — a production system next to a test client, or several customers' systems behind one n8n — put the connections into a `destinations.json` and point the server at it:
165
+
166
+ ```bash
167
+ guniweb-sap-mcp --transport http --port 8808 --destinations /config/destinations.json
168
+ # or: SAP_MCP_DESTINATIONS=/config/destinations.json
169
+ ```
170
+
171
+ ```json
172
+ {
173
+ "version": 1,
174
+ "destinations": {
175
+ "default": {
176
+ "baseUrl": "https://s4prod.example.com",
177
+ "authType": "basic",
178
+ "username": "MCP_USER",
179
+ "password": "${S4PROD_PASSWORD}",
180
+ "sapClient": "100"
181
+ },
182
+ "s4test": {
183
+ "baseUrl": "https://s4test.example.com",
184
+ "authType": "basic",
185
+ "username": "MCP_USER",
186
+ "password": "${S4TEST_PASSWORD}",
187
+ "sapClient": "200"
188
+ }
189
+ }
190
+ }
191
+ ```
192
+
193
+ - **One entry = one connection.** Every entry accepts exactly the fields the `SAP_*` variables accept (`authType` and its credentials, `sapClient`, `baseUrl`) — all seven authentication types work per destination.
194
+ - **Secrets stay out of the file.** Any string may contain `${VARIABLE}`; it is resolved from the environment at load time (Docker secrets, `.env`). A placeholder that cannot be resolved makes the file invalid — an empty password would otherwise surface as a misleading `401` from SAP.
195
+ - **Hot reload.** The file is watched; a change is picked up on the next request, no restart. `kill -HUP <pid>` forces a reload. An invalid file **never** replaces the running configuration — the error is logged and the last valid one stays in force.
196
+ - **Backwards compatible.** Without `--destinations` nothing changes: the `SAP_*` variables are the single destination `default`. With the file, `SAP_*` (if set) is added as `default` unless the file defines one — so an installation can migrate without a gap.
197
+ - **Which destination answers?** Without a token: the one named `default` (or the only one). With a token: the token's destination — see below.
198
+
199
+ **Tokens: one Bearer value per n8n credential selects the system.** The n8n MCP Client sends exactly one `Authorization` header, so a token does two jobs at once — it authenticates the request and picks the destination. SAP passwords never leave the server; n8n only holds the token.
200
+
201
+ ```json
202
+ {
203
+ "version": 1,
204
+ "destinations": { "s4prod": { "...": "..." }, "s4test": { "...": "..." } },
205
+ "tokens": [
206
+ { "hash": "sha256:9f2c…", "destination": "s4prod", "label": "n8n prod workflows" },
207
+ { "hash": "sha256:1a7e…", "destination": "s4test", "label": "n8n dev", "revoked": false }
208
+ ]
209
+ }
210
+ ```
211
+
212
+ - The file holds only the **SHA-256 hash**; the plain token (`gsm_<destination>_<random>`) is printed **once** by `tokens issue` and cannot be recovered afterwards. Put it into an n8n **Bearer Auth** credential on the MCP Client node. As soon as at least one token exists, requests without a valid token get `401` — `--api-key` keeps working alongside and serves the `default` destination.
213
+ - Lookup is constant-time over all entries; `"revoked": true` disables a token on the next request (hot reload, no restart). After 10 failed attempts within a minute the client address gets `429` with `Retry-After` — also for a valid token — as a brake on guessing (a reverse proxy with a real rate limit is still the right place for that). Tokens are never written to the log; rejections are logged with address and reason.
214
+ **Managing the file — no hand-editing needed.** The same binary carries the admin commands; every command validates the result exactly like the server does before writing (never leaves an unloadable file), writes atomically, and a running server picks the change up on the next request:
215
+
216
+ ```bash
217
+ export SAP_MCP_DESTINATIONS=/config/destinations.json # or --destinations <path> on each call
218
+
219
+ guniweb-sap-mcp destinations add s4prod --base-url https://s4prod.example.com --auth-type basic \
220
+ --username MCP_USER --password '${S4PROD_PASSWORD}' --sap-client 100
221
+ guniweb-sap-mcp destinations add legacy --from-env # copy the current SAP_* environment (migration)
222
+ guniweb-sap-mcp destinations list # names, URLs, auth type, client, token count — never secrets
223
+
224
+ guniweb-sap-mcp tokens issue s4prod --label "n8n prod" --read-only --tiers core,odata
225
+ # → prints the plain token ONCE on stdout (everything else goes to stderr, so TOKEN=$(…) works)
226
+ guniweb-sap-mcp tokens list # label, destination, hash prefix, policy, status
227
+ guniweb-sap-mcp tokens revoke "n8n prod" # by label or ≥ 8 hex chars of the hash
228
+ guniweb-sap-mcp destinations remove s4test [--force] # refuses while tokens point at it, unless --force
229
+ ```
230
+
231
+ `--set field=value` covers any auth type (`--auth-type oauth2 --set clientId=… --set clientSecret='${BTP_SECRET}' --set tokenServiceUrl=…`); `${ENV_VAR}` values are checked against the current environment (`--allow-missing-env` to skip, e.g. when the variable only exists inside the container). In Docker, run the commands inside the container so they see the same file and environment: `docker compose exec sap-mcp guniweb-sap-mcp tokens issue s4prod`.
232
+
233
+ - **Per-token permissions** — `"policy": { "readOnly": true, "tiers": ["core", "odata"] }`. A policy only ever *restricts*: `readOnly: true` hides the write tools for that token even when the server runs with `--allow-write` (`readOnly: false` cannot open a read-only server), and `tiers` is a ceiling — tiers outside it are invisible for that token and `sap_enable_tools` cannot switch them on. `tools/list` is filtered per request, so an agent on a read-only token never sees `sap_create` in the first place.
234
+
104
235
  ### 4. Use with n8n
105
236
 
106
237
  1. Add an **AI Agent** node with an **MCP Client Tool** sub-node
@@ -165,16 +296,16 @@ In addition to the variables documented in [Quick Start](#quick-start):
165
296
  ### Core Tier (always active)
166
297
  | Tool | Description |
167
298
  |------|-------------|
168
- | `test-connection` | Test connectivity to SAP (pings catalog service) |
169
- | `sap_list_services` | List entity sets in an OData service |
299
+ | `test-connection` | Stage-by-stage connection diagnosis (DNS → TLS → auth → client → catalog); names the first failing stage |
300
+ | `sap_list_services` | List entity sets in an OData service (paged, default 50) |
170
301
  | `sap_discover_services` | Discover all services with category filtering and search |
171
- | `sap_get_metadata` | Inspect entity types, properties, keys (with search filter) |
302
+ | `sap_get_metadata` | Inspect entity types, properties, keys, and what SAP allows per entity set (creatable/updatable/deletable) |
172
303
 
173
304
  ### OData Tier (active by default)
174
305
  | Tool | Description |
175
306
  |------|-------------|
176
307
  | `sap_read` | Read a single entity or collection |
177
- | `sap_query` | Query with OData $filter, $select, $expand, pagination |
308
+ | `sap_query` | Query with OData $filter, $select, $expand, paging (default `top` 50) |
178
309
  | `sap_nl_query` | Natural language query with structured filters |
179
310
  | `sap_create` | Create entities (supports deep insert + draft services) |
180
311
  | `sap_update` | Update entities (automatic ETag handling) |