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 +85 -3
- package/index.js +27 -1
- package/lib/s4hClient.js +19 -2
- package/package.json +1 -1
- package/tools/transferOrderHistory.js +146 -0
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
|
|
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 |
|
|
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.
|
|
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
|
-
|
|
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
|
+
"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
|
+
}
|