sap-wm-mcp 0.2.3 → 0.2.5

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 CHANGED
@@ -13,7 +13,7 @@ Connect AI agents — Claude, Copilot, or any MCP-compatible client — directly
13
13
  >
14
14
  > This project ships a custom RAP OData V4 service that exposes classic WM operations as a proper API — and wraps it in an MCP server so AI agents can drive it. Large portions of the SAP install base are still on classic WM. This fills the gap.
15
15
 
16
- The **npm package** ships 18 tools covering core operations, analytics, shift management, and anomaly detection. This repository contains the 9 open-sourced tool implementations — the ABAP RAP service source and additional tool source are available separately (see [ABAP Service Installation](#abap-service-installation)).
16
+ The **npm package** ships 19 tools covering core operations, analytics, shift management, anomaly detection, and audit history. This repository contains the 9 open-sourced tool implementations — the ABAP RAP service source and additional tool source are available separately (see [ABAP Service Installation](#abap-service-installation)).
17
17
 
18
18
  ---
19
19
 
@@ -26,6 +26,7 @@ The **npm package** ships 18 tools covering core operations, analytics, shift ma
26
26
  - [MCP Client Setup](#mcp-client-setup)
27
27
  - [Option A — npx (Claude Desktop, Claude Code, Cursor)](#option-a--npx-recommended)
28
28
  - [Option B — Clone locally](#option-b--clone-locally-for-developers)
29
+ - [Windows troubleshooting (Claude Code)](#windows-claude-code)
29
30
  - [Verify it's working](#verify-its-working)
30
31
  - [Example conversations](#example-conversations)
31
32
  - [Tools Reference](#tools-reference)
@@ -194,6 +195,75 @@ The sap-wm-mcp tools appear automatically in the tools panel. You'll see a hamme
194
195
 
195
196
  > **`.mcp.json` is project-scoped.** Add it to `.gitignore` — it contains credentials.
196
197
 
198
+ > **Windows users:** If the server doesn't appear after restart, see [Windows troubleshooting](#windows-claude-code) below.
199
+
200
+ ---
201
+
202
+ #### Claude Code — Windows
203
+
204
+ On Windows, Claude Code may silently fail to launch `npx` or `node` directly (known issue with process spawning). If the server doesn't appear in `/mcp`, use this pattern instead.
205
+
206
+ **Step 1 — Create a wrapper script** (requires [Git for Windows](https://gitforwindows.org)):
207
+
208
+ Save as `scripts/run-sap-wm-mcp.sh` in your project:
209
+
210
+ ```bash
211
+ #!/bin/bash
212
+ SCRIPT_DIR="$(cd "${BASH_SOURCE[0]%/*}" && pwd)"
213
+ ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
214
+
215
+ exec npx --prefix "$ROOT_DIR" sap-wm-mcp
216
+ ```
217
+
218
+ Or to load credentials from a `.env` file instead of inline in `.mcp.json`:
219
+
220
+ ```bash
221
+ #!/bin/bash
222
+ SCRIPT_DIR="$(cd "${BASH_SOURCE[0]%/*}" && pwd)"
223
+ ENV_FILE="$(cd "$SCRIPT_DIR/.." && pwd)/.env"
224
+
225
+ if [ -f "$ENV_FILE" ]; then
226
+ set -a; source "$ENV_FILE"; set +a
227
+ else
228
+ echo "ERROR: .env not found" >&2; exit 1
229
+ fi
230
+
231
+ exec npx sap-wm-mcp
232
+ ```
233
+
234
+ **Step 2 — Update `.mcp.json`** to use `bash.exe` as the command:
235
+
236
+ ```json
237
+ {
238
+ "mcpServers": {
239
+ "sap-wm-mcp": {
240
+ "command": "C:/Program Files/Git/usr/bin/bash.exe",
241
+ "args": [
242
+ "C:/absolute/path/to/your/project/scripts/run-sap-wm-mcp.sh"
243
+ ]
244
+ }
245
+ }
246
+ }
247
+ ```
248
+
249
+ Use an absolute path with forward slashes. No `env` block needed if credentials are in `.env`.
250
+
251
+ **Step 3 — Skip the approval dialog** (optional but recommended):
252
+
253
+ Create `.claude/settings.local.json` in your project root:
254
+
255
+ ```json
256
+ {
257
+ "enableAllProjectMcpServers": true
258
+ }
259
+ ```
260
+
261
+ This prevents a silently-dismissed approval prompt from blocking the server on every restart.
262
+
263
+ **Step 4 — Restart Claude Code.** Run `/mcp` to verify the tools are loaded.
264
+
265
+ > **Note:** If the server was previously rejected in the approval dialog, run `claude mcp reset-project-choices` before restarting.
266
+
197
267
  ---
198
268
 
199
269
  #### Cursor, Windsurf, and other MCP clients
@@ -232,7 +302,7 @@ node index.js
232
302
 
233
303
  Then point your MCP client at the local file:
234
304
 
235
- **Claude Desktop:**
305
+ **Claude Desktop / macOS / Linux:**
236
306
  ```json
237
307
  {
238
308
  "mcpServers": {
@@ -244,6 +314,8 @@ Then point your MCP client at the local file:
244
314
  }
245
315
  ```
246
316
 
317
+ **Claude Code on Windows** — use the bash wrapper pattern (see [Windows troubleshooting](#windows-claude-code)) with `exec node "$ROOT_DIR/index.js"` in the script instead of `npx`.
318
+
247
319
  ---
248
320
 
249
321
  ### Verify it's working
@@ -290,6 +362,14 @@ For write operations:
290
362
  "Are there any fragmented quants that need consolidation TOs?"
291
363
  ```
292
364
 
365
+ **Audit and history**
366
+ ```
367
+ "Show me all transfer orders created in warehouse 102 this month"
368
+ "Which TOs did NOMANH create last week?"
369
+ "Show me all open TOs created more than 3 days ago — something is stuck"
370
+ "Give me a history of movement type 999 TOs for material TG0001"
371
+ ```
372
+
293
373
  ---
294
374
 
295
375
  ## Tools Reference
@@ -439,6 +519,7 @@ The following tools are available in the published npm package and fully functio
439
519
  | `get_quant_fragmentation` | Bin+material combinations with excessive quant count — consolidation candidates |
440
520
  | `get_unresolved_su_negatives` | Persistent negative quants in SU zones older than a configurable age |
441
521
  | `get_inventory_anomalies` | Bins stuck in mid-inventory state — empty bins with locks, open count docs, orphaned lock codes |
522
+ | `get_transfer_order_history` | Full TO history with creation date, creator, confirmation date, executor, duration, and item detail — filterable by date range, status, movement type, material, or user |
442
523
 
443
524
  ---
444
525
 
@@ -577,6 +658,7 @@ sap-wm-mcp/
577
658
  │ ├── createTransferOrder.js ← create_transfer_order
578
659
  │ ├── confirmTransferOrder.js ← confirm_transfer_order
579
660
  │ └── confirmTransferOrderSU.js ← confirm_transfer_order_su
661
+ │ └── transferOrderHistory.js ← get_transfer_order_history
580
662
  ├── .env.example
581
663
  └── package.json
582
664
  ```
@@ -601,7 +683,7 @@ Running both side-by-side shows the contrast directly: same tools, same question
601
683
  | Phase | Status | Description |
602
684
  |---|---|---|
603
685
  | Phase 0 — RAP Service | ✅ Complete | Custom OData V4 service with 7 entity sets over classic WM tables |
604
- | Phase 1 — Local MCP | ✅ Complete | 18 tools working, security hardened, published to npm |
686
+ | Phase 1 — Local MCP | ✅ Complete | 19 tools working, security hardened, published to npm |
605
687
  | Phase 2 — BTP CF | 🔜 Planned | Deploy to SAP BTP Cloud Foundry with SSE transport + XSUAA + Cloud Connector |
606
688
  | Phase 3 — Joule Agent | 💡 Future | Native Joule Studio agent using the same RAP service |
607
689
 
package/index.js CHANGED
@@ -25,8 +25,9 @@ import { getGoodsReceiptMonitor } from './tools/goodsReceiptMonitor.js';
25
25
  import { getQuantFragmentation } from './tools/quantFragmentation.js';
26
26
  import { getUnresolvedSuNegatives } from './tools/unresolvedSuNegatives.js';
27
27
  import { getInventoryAnomalies } from './tools/inventoryAnomalies.js';
28
+ import { getTransferOrderHistory } from './tools/transferOrderHistory.js';
28
29
 
29
- const server = new McpServer({ name: 'sap-wm-mcp', version: '0.2.0' });
30
+ const server = new McpServer({ name: 'sap-wm-mcp', version: '0.2.5' });
30
31
 
31
32
  // Tool 1 — get_bin_status
32
33
  server.tool(
@@ -388,6 +389,31 @@ server.tool(
388
389
  }
389
390
  );
390
391
 
392
+ // Tool 19 — get_transfer_order_history
393
+ server.tool(
394
+ 'get_transfer_order_history',
395
+ 'Get the full history of classic WM Transfer Orders in S/4HANA — when they were created, by whom, when confirmed, who executed them, and how long they took. Filter by date range, status (open/confirmed/all), movement type, material, creator, or executor.',
396
+ {
397
+ warehouse: z.string().describe('Warehouse number e.g. 102'),
398
+ dateFrom: z.string().optional().describe('Filter TOs created from this date (YYYY-MM-DD) e.g. 2026-01-01'),
399
+ dateTo: z.string().optional().describe('Filter TOs created up to this date (YYYY-MM-DD) e.g. 2026-03-31'),
400
+ status: z.enum(['open', 'confirmed', 'all']).optional().default('all').describe('Filter by confirmation status — open, confirmed, or all (default: all)'),
401
+ movementType: z.string().optional().describe('Filter by WM movement type e.g. 999'),
402
+ createdBy: z.string().optional().describe('Filter by the SAP user who created the TO e.g. NOMANH'),
403
+ executedBy: z.string().optional().describe('Filter by the SAP user who executed/confirmed the TO'),
404
+ material: z.string().optional().describe('Filter by material number e.g. TG0001'),
405
+ top: z.number().optional().default(50).describe('Max number of TOs to return (default 50)')
406
+ },
407
+ async (params) => {
408
+ try {
409
+ const result = await getTransferOrderHistory(params);
410
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
411
+ } catch (err) {
412
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
413
+ }
414
+ }
415
+ );
416
+
391
417
  const transport = new StdioServerTransport();
392
418
  await server.connect(transport);
393
419
  console.error('SAP WM MCP Server running (stdio)...');
package/lib/s4hClient.js CHANGED
@@ -130,8 +130,25 @@ export async function s4hPost(path, body) {
130
130
 
131
131
  if (!response.ok) {
132
132
  const text = await response.text();
133
- log('error', 'odata_post_http_error', { url, status: response.status, ms: Date.now() - start });
134
- throw new Error(`OData POST failed [${response.status}] — see server log for details`);
133
+ log('error', 'odata_post_http_error', { url, status: response.status, body: text, ms: Date.now() - start });
134
+
135
+ // Extract the SAP business error message from the OData V4 error body.
136
+ // RAP surfaces reported[] messages in error.innererror.errordetails[].
137
+ // Only the message string is returned — the raw body stays in the log.
138
+ let sapMessage = null;
139
+ try {
140
+ const errJson = JSON.parse(text);
141
+ const details = errJson?.error?.innererror?.errordetails;
142
+ if (Array.isArray(details) && details.length > 0) {
143
+ sapMessage = details.find(d => d.severity === 'error')?.message ?? details[0]?.message;
144
+ }
145
+ sapMessage = sapMessage ?? errJson?.error?.message ?? null;
146
+ } catch { /* non-JSON body — fall through to generic message */ }
147
+
148
+ const msg = sapMessage
149
+ ? `OData POST failed [${response.status}]: ${sapMessage}`
150
+ : `OData POST failed [${response.status}] — see server log for details`;
151
+ throw new Error(msg);
135
152
  }
136
153
 
137
154
  log('debug', 'odata_post_ok', { url, ms: Date.now() - start });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sap-wm-mcp",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "MCP server for SAP Classic Warehouse Management — connects AI agents to S/4HANA WM via a custom RAP OData V4 service. For systems where EWM is not active.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -0,0 +1,146 @@
1
+ import { s4hGet } from '../lib/s4hClient.js';
2
+ import { esc } from '../lib/sanitize.js';
3
+
4
+ const BASE_HEADER = `/sap/opu/odata4/iwbep/all/srvd/sap/zsd_wmmcpservice/0001/WMTransferOrder`;
5
+ const BASE_ITEM = `/sap/opu/odata4/iwbep/all/srvd/sap/zsd_wmmcpservice/0001/WMTransferOrderItem`;
6
+
7
+ export async function getTransferOrderHistory({
8
+ warehouse,
9
+ dateFrom,
10
+ dateTo,
11
+ status = 'all',
12
+ movementType,
13
+ createdBy,
14
+ executedBy,
15
+ material,
16
+ top = 50
17
+ }) {
18
+
19
+ // Step 1 — Build header filters
20
+ const headerFilters = [`WarehouseNumber eq '${esc(warehouse)}'`];
21
+
22
+ if (status === 'confirmed') headerFilters.push(`IsConfirmed eq true`);
23
+ if (status === 'open') headerFilters.push(`IsConfirmed ne true`);
24
+
25
+ if (dateFrom) headerFilters.push(`CreatedDate ge ${esc(dateFrom)}`);
26
+ if (dateTo) headerFilters.push(`CreatedDate le ${esc(dateTo)}`);
27
+ if (movementType) headerFilters.push(`MovementType eq '${esc(movementType)}'`);
28
+ if (createdBy) headerFilters.push(`CreatedBy eq '${esc(createdBy)}'`);
29
+ if (executedBy) headerFilters.push(`ExecutedBy eq '${esc(executedBy)}'`);
30
+
31
+ const headerSelect = [
32
+ 'WarehouseNumber','TransferOrderNumber','MovementType','IsConfirmed',
33
+ 'CreatedDate','CreatedTime','CreatedBy',
34
+ 'ConfirmedDate','ExecutedBy','StartDate','StartTime','EndDate','EndTime',
35
+ 'TransferReqNumber','NumberOfItems'
36
+ ].join(',');
37
+
38
+ const headerPath = `${BASE_HEADER}?$filter=${encodeURIComponent(headerFilters.join(' and '))}&$orderby=CreatedDate desc,CreatedTime desc&$top=${top}&$select=${headerSelect}`;
39
+ const headerData = await s4hGet(headerPath);
40
+ const headers = headerData.value ?? [];
41
+
42
+ if (headers.length === 0) {
43
+ return {
44
+ count: 0,
45
+ truncated: false,
46
+ warehouse,
47
+ filters: { dateFrom: dateFrom ?? 'all', dateTo: dateTo ?? 'all', status, movementType: movementType ?? 'all' },
48
+ orders: []
49
+ };
50
+ }
51
+
52
+ // Step 2 — Fetch items for the returned TOs
53
+ const toNumbers = [...new Set(headers.map(h => h.TransferOrderNumber))];
54
+ const toFilter = toNumbers.map(n => `TransferOrderNumber eq '${esc(n)}'`).join(' or ');
55
+
56
+ const itemFilters = [`WarehouseNumber eq '${esc(warehouse)}'`, `(${toFilter})`];
57
+ if (material) itemFilters.push(`Material eq '${esc(material)}'`);
58
+
59
+ const itemPath = `${BASE_ITEM}?$filter=${encodeURIComponent(itemFilters.join(' and '))}&$top=${top * 10}`;
60
+ const itemData = await s4hGet(itemPath);
61
+ const allItems = itemData.value ?? [];
62
+
63
+ // Step 3 — Group items by TO number
64
+ const itemsByTo = {};
65
+ for (const item of allItems) {
66
+ const key = item.TransferOrderNumber;
67
+ if (!itemsByTo[key]) itemsByTo[key] = [];
68
+ itemsByTo[key].push({
69
+ item: item.TransferOrderItem,
70
+ material: item.Material?.trimStart?.() ?? item.Material,
71
+ plant: item.Plant,
72
+ sourceType: item.SourceStorageType,
73
+ sourceBin: item.SourceBin,
74
+ destType: item.DestStorageType,
75
+ destBin: item.DestBin,
76
+ requiredQty: parseFloat(item.RequiredQuantity) || 0,
77
+ confirmedQty: parseFloat(item.ConfirmedQuantity) || 0,
78
+ uom: item.UnitOfMeasure
79
+ });
80
+ }
81
+
82
+ // Step 4 — If material filter active, drop TOs that have no matching items
83
+ const filteredHeaders = material
84
+ ? headers.filter(h => itemsByTo[h.TransferOrderNumber]?.length > 0)
85
+ : headers;
86
+
87
+ // Step 5 — Build response
88
+ const today = new Date();
89
+
90
+ const orders = filteredHeaders.map(h => {
91
+ const items = itemsByTo[h.TransferOrderNumber] ?? [];
92
+ const createdAt = h.CreatedDate ? new Date(h.CreatedDate) : null;
93
+ const daysSince = createdAt ? Math.floor((today - createdAt) / 86400000) : null;
94
+ const isConfirmed = h.IsConfirmed === true || h.IsConfirmed === 'X';
95
+
96
+ // Duration: minutes between StartDate/Time and EndDate/Time
97
+ let durationMinutes = null;
98
+ if (h.StartDate && h.EndDate && h.StartTime && h.EndTime) {
99
+ const start = new Date(`${h.StartDate}T${h.StartTime}`);
100
+ const end = new Date(`${h.EndDate}T${h.EndTime}`);
101
+ if (!isNaN(start) && !isNaN(end)) {
102
+ durationMinutes = Math.round((end - start) / 60000);
103
+ }
104
+ }
105
+
106
+ return {
107
+ toNumber: h.TransferOrderNumber,
108
+ status: isConfirmed ? 'confirmed' : 'open',
109
+ movementType: h.MovementType,
110
+ transferReqNumber: h.TransferReqNumber || null,
111
+ numberOfItems: parseInt(h.NumberOfItems) || items.length,
112
+ created: {
113
+ date: h.CreatedDate,
114
+ time: h.CreatedTime,
115
+ by: h.CreatedBy,
116
+ daysAgo: daysSince
117
+ },
118
+ execution: isConfirmed ? {
119
+ confirmedDate: h.ConfirmedDate || null,
120
+ executedBy: h.ExecutedBy || null,
121
+ startDate: h.StartDate || null,
122
+ startTime: h.StartTime || null,
123
+ endDate: h.EndDate || null,
124
+ endTime: h.EndTime || null,
125
+ durationMinutes
126
+ } : null,
127
+ items
128
+ };
129
+ });
130
+
131
+ return {
132
+ count: orders.length,
133
+ truncated: headers.length === top,
134
+ warehouse,
135
+ filters: {
136
+ dateFrom: dateFrom ?? 'all',
137
+ dateTo: dateTo ?? 'all',
138
+ status,
139
+ movementType: movementType ?? 'all',
140
+ createdBy: createdBy ?? 'all',
141
+ executedBy: executedBy ?? 'all',
142
+ material: material ?? 'all'
143
+ },
144
+ orders
145
+ };
146
+ }