sap-wm-mcp 0.1.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.
package/index.js ADDED
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { z } from 'zod';
5
+ import 'dotenv/config';
6
+
7
+ import { getBinStatus } from './tools/binStatus.js';
8
+ import { getStockForMaterial } from './tools/stockByMaterial.js';
9
+ import { findEmptyBins } from './tools/emptyBins.js';
10
+ import { getBinUtilization } from './tools/binUtilization.js';
11
+ import { createTransferOrder } from './tools/createTransferOrder.js';
12
+ import { confirmTransferOrder } from './tools/confirmTransferOrder.js';
13
+ import { confirmTransferOrderSU } from './tools/confirmTransferOrderSU.js';
14
+
15
+ const server = new McpServer({ name: 'sap-wm-mcp', version: '0.1.0' });
16
+
17
+ // Tool 1 — get_bin_status
18
+ server.tool(
19
+ 'get_bin_status',
20
+ 'Query classic WM storage bins from S/4HANA by warehouse, storage type, or specific bin — returns empty/blocked status and capacity',
21
+ {
22
+ warehouse: z.string().describe('Warehouse number e.g. 102'),
23
+ storageType: z.string().optional().describe('Storage type e.g. 001'),
24
+ bin: z.string().optional().describe('Specific bin number e.g. 01-01-01'),
25
+ top: z.number().optional().default(20).describe('Max records to return')
26
+ },
27
+ async (params) => {
28
+ try {
29
+ const result = await getBinStatus(params);
30
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
31
+ } catch (err) {
32
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
33
+ }
34
+ }
35
+ );
36
+
37
+ // Tool 2 — get_stock_for_material
38
+ server.tool(
39
+ 'get_stock_for_material',
40
+ 'Get physical warehouse stock for a material in classic WM — shows which bins hold the material and how much',
41
+ {
42
+ warehouse: z.string().describe('Warehouse number e.g. 102'),
43
+ material: z.string().optional().describe('Material number e.g. TG0001'),
44
+ storageType: z.string().optional().describe('Filter by storage type'),
45
+ top: z.number().optional().default(20).describe('Max records to return')
46
+ },
47
+ async (params) => {
48
+ try {
49
+ const result = await getStockForMaterial(params);
50
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
51
+ } catch (err) {
52
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
53
+ }
54
+ }
55
+ );
56
+
57
+ // Tool 3 — find_empty_bins
58
+ server.tool(
59
+ 'find_empty_bins',
60
+ 'Find all empty storage bins in a classic WM warehouse, optionally filtered by storage type',
61
+ {
62
+ warehouse: z.string().describe('Warehouse number e.g. 102'),
63
+ storageType: z.string().optional().describe('Storage type to filter e.g. 001'),
64
+ top: z.number().optional().default(50).describe('Max records to return')
65
+ },
66
+ async (params) => {
67
+ try {
68
+ const result = await findEmptyBins(params);
69
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
70
+ } catch (err) {
71
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
72
+ }
73
+ }
74
+ );
75
+
76
+ // Tool 4 — get_bin_utilization
77
+ server.tool(
78
+ 'get_bin_utilization',
79
+ 'Get warehouse bin utilization stats for classic WM — occupied vs empty vs blocked, grouped by storage type',
80
+ {
81
+ warehouse: z.string().describe('Warehouse number e.g. 102'),
82
+ storageType: z.string().optional().describe('Filter by storage type'),
83
+ top: z.number().optional().default(100).describe('Max bins to analyze')
84
+ },
85
+ async (params) => {
86
+ try {
87
+ const result = await getBinUtilization(params);
88
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
89
+ } catch (err) {
90
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
91
+ }
92
+ }
93
+ );
94
+
95
+ // Tool 5 — create_transfer_order
96
+ server.tool(
97
+ 'create_transfer_order',
98
+ 'Create a classic WM Transfer Order in S/4HANA — moves stock from source bin to destination bin via L_TO_CREATE_SINGLE',
99
+ {
100
+ warehouse: z.string().describe('Warehouse number e.g. 102'),
101
+ movementType: z.string().describe('WM movement type e.g. 999'),
102
+ material: z.string().describe('Material number e.g. TG0001'),
103
+ plant: z.string().describe('Plant e.g. 1710'),
104
+ quantity: z.number().describe('Quantity to move'),
105
+ unitOfMeasure: z.string().describe('Unit of measure e.g. ST, KG'),
106
+ sourceType: z.string().optional().default('').describe('Source storage type e.g. 001'),
107
+ sourceBin: z.string().optional().default('').describe('Source bin e.g. 01-02-01'),
108
+ sourceStorageUnit: z.string().optional().default('').describe('Source storage unit (LENUM) — required for SU-managed types e.g. 00000000001000000017'),
109
+ destType: z.string().describe('Destination storage type e.g. 001'),
110
+ destBin: z.string().describe('Destination bin e.g. 01-06-03'),
111
+ destStorageUnit: z.string().optional().default('').describe('Destination storage unit (LENUM) — for SU-managed types, same as source SU when moving full SU')
112
+ },
113
+ async (params) => {
114
+ try {
115
+ const result = await createTransferOrder(params);
116
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
117
+ } catch (err) {
118
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
119
+ }
120
+ }
121
+ );
122
+
123
+ // Tool 6 — confirm_transfer_order
124
+ server.tool(
125
+ 'confirm_transfer_order',
126
+ 'Confirm a classic WM Transfer Order in S/4HANA — marks the TO as executed via L_TO_CONFIRM',
127
+ {
128
+ warehouse: z.string().describe('Warehouse number e.g. 102'),
129
+ transferOrderNumber: z.string().describe('Transfer order number e.g. 0000000123')
130
+ },
131
+ async (params) => {
132
+ try {
133
+ const result = await confirmTransferOrder(params);
134
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
135
+ } catch (err) {
136
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
137
+ }
138
+ }
139
+ );
140
+
141
+ // Tool 7 — confirm_transfer_order_su
142
+ server.tool(
143
+ 'confirm_transfer_order_su',
144
+ 'Confirm all open transfer orders on a classic WM storage unit — uses L_TO_CONFIRM_SU to confirm by SU number instead of individual TO number',
145
+ {
146
+ warehouse: z.string().describe('Warehouse number e.g. 102'),
147
+ storageUnit: z.string().describe('Storage unit number (LENUM) e.g. 000000001234567890')
148
+ },
149
+ async (params) => {
150
+ try {
151
+ const result = await confirmTransferOrderSU(params);
152
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
153
+ } catch (err) {
154
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
155
+ }
156
+ }
157
+ );
158
+
159
+ const transport = new StdioServerTransport();
160
+ await server.connect(transport);
161
+ console.error('SAP WM MCP Server running (stdio)...');
@@ -0,0 +1,68 @@
1
+ import fetch from 'node-fetch';
2
+ import https from 'https';
3
+ import 'dotenv/config';
4
+
5
+ const agent = new https.Agent({ rejectUnauthorized: false });
6
+ const BASE_URL = process.env.SAP_URL;
7
+ const AUTH = Buffer.from(`${process.env.SAP_USER}:${process.env.SAP_PASSWORD}`).toString('base64');
8
+ const CLIENT = process.env.SAP_CLIENT;
9
+
10
+ export async function s4hGet(path) {
11
+ const url = `${BASE_URL}${path}`;
12
+ const response = await fetch(url, {
13
+ headers: {
14
+ 'Authorization': `Basic ${AUTH}`,
15
+ 'Accept': 'application/json',
16
+ 'sap-client': CLIENT
17
+ },
18
+ agent
19
+ });
20
+ if (!response.ok) {
21
+ throw new Error(`S4H OData error ${response.status}: ${await response.text()}`);
22
+ }
23
+ return response.json();
24
+ }
25
+
26
+ export async function s4hPost(path, body) {
27
+ const BASE_PATH = `/sap/opu/odata4/iwbep/all/srvd/sap/zsd_wmmcpservice/0001/`;
28
+
29
+ // Fetch CSRF token
30
+ const tokenRes = await fetch(`${BASE_URL}${BASE_PATH}`, {
31
+ method: 'GET',
32
+ headers: {
33
+ 'Authorization': `Basic ${AUTH}`,
34
+ 'x-csrf-token': 'fetch',
35
+ 'sap-client': CLIENT
36
+ },
37
+ agent
38
+ });
39
+ const csrfToken = tokenRes.headers.get('x-csrf-token');
40
+ if (!csrfToken) throw new Error('CSRF token fetch failed — no token in response headers');
41
+
42
+ // Forward session cookie so the token remains valid for this POST
43
+ const cookies = tokenRes.headers.raw?.()['set-cookie'] ?? tokenRes.headers.get('set-cookie');
44
+ const cookieHeader = Array.isArray(cookies)
45
+ ? cookies.map(c => c.split(';')[0]).join('; ')
46
+ : (cookies ?? '').split(',').map(c => c.trim().split(';')[0]).join('; ');
47
+
48
+ const url = `${BASE_URL}${path}`;
49
+ const response = await fetch(url, {
50
+ method: 'POST',
51
+ headers: {
52
+ 'Authorization': `Basic ${AUTH}`,
53
+ 'Content-Type': 'application/json',
54
+ 'Accept': 'application/json',
55
+ 'x-csrf-token': csrfToken,
56
+ 'sap-client': CLIENT,
57
+ ...(cookieHeader ? { 'Cookie': cookieHeader } : {})
58
+ },
59
+ body: JSON.stringify(body),
60
+ agent
61
+ });
62
+ if (!response.ok) {
63
+ throw new Error(`S4H OData POST error ${response.status}: ${await response.text()}`);
64
+ }
65
+ // 204 No Content is valid for some actions
66
+ const text = await response.text();
67
+ return text ? JSON.parse(text) : {};
68
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "sap-wm-mcp",
3
+ "version": "0.1.0",
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
+ "type": "module",
6
+ "main": "index.js",
7
+ "bin": {
8
+ "sap-wm-mcp": "index.js"
9
+ },
10
+ "files": [
11
+ "index.js",
12
+ "lib/",
13
+ "tools/"
14
+ ],
15
+ "scripts": {
16
+ "start": "node index.js"
17
+ },
18
+ "keywords": [
19
+ "sap",
20
+ "wm",
21
+ "warehouse-management",
22
+ "mcp",
23
+ "model-context-protocol",
24
+ "s4hana",
25
+ "warehouse",
26
+ "odata",
27
+ "rap",
28
+ "claude",
29
+ "ai-agents"
30
+ ],
31
+ "author": "Noman Mohamed Hanif <noman@relacon.de> (https://github.com/CodeOfHANA)",
32
+ "license": "MIT",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "https://github.com/CodeOfHANA/sap-wm-mcp.git"
36
+ },
37
+ "homepage": "https://github.com/CodeOfHANA/sap-wm-mcp#readme",
38
+ "engines": {
39
+ "node": ">=20"
40
+ },
41
+ "dependencies": {
42
+ "@modelcontextprotocol/sdk": "^1.27.1",
43
+ "dotenv": "^16.6.1",
44
+ "node-fetch": "^3.3.2",
45
+ "zod": "^3.25.76"
46
+ }
47
+ }
@@ -0,0 +1,35 @@
1
+ import { s4hGet } from '../lib/s4hClient.js';
2
+
3
+ const BASE = `/sap/opu/odata4/iwbep/all/srvd/sap/zsd_wmmcpservice/0001/WMStorageBin`;
4
+
5
+ export async function getBinStatus({ warehouse, storageType, bin, top = 20 }) {
6
+ const filters = [`WarehouseNumber eq '${warehouse}'`];
7
+ if (storageType) filters.push(`StorageType eq '${storageType}'`);
8
+ if (bin) filters.push(`StorageBin eq '${bin}'`);
9
+
10
+ const path = `${BASE}?$filter=${encodeURIComponent(filters.join(' and '))}&$top=${top}`;
11
+ const data = await s4hGet(path);
12
+
13
+ return {
14
+ count: data.value.length,
15
+ warehouse,
16
+ bins: data.value.map(b => ({
17
+ bin: b.StorageBin,
18
+ storageType: b.StorageType,
19
+ storageSection: b.StorageSection,
20
+ binType: b.StorageBinType,
21
+ empty: b.IsEmpty,
22
+ full: b.IsFull,
23
+ blockedPutaway: b.PutawayBlock,
24
+ blockedRemoval: b.RemovalBlock,
25
+ quants: b.NumberOfQuants,
26
+ maxWeight: b.MaximumWeight,
27
+ occupiedWeight: b.OccupiedWeight,
28
+ weightUnit: b.WeightUnit,
29
+ totalCapacity: b.TotalCapacity,
30
+ remainingCapacity: b.RemainingCapacity,
31
+ lastMovement: b.LastMovementDate,
32
+ dynamic: b.IsDynamicBin
33
+ }))
34
+ };
35
+ }
@@ -0,0 +1,48 @@
1
+ import { s4hGet } from '../lib/s4hClient.js';
2
+
3
+ const BASE = `/sap/opu/odata4/iwbep/all/srvd/sap/zsd_wmmcpservice/0001/WMStorageBin`;
4
+
5
+ export async function getBinUtilization({ warehouse, storageType, top = 100 }) {
6
+ const filters = [`WarehouseNumber eq '${warehouse}'`];
7
+ if (storageType) filters.push(`StorageType eq '${storageType}'`);
8
+
9
+ const path = `${BASE}?$filter=${encodeURIComponent(filters.join(' and '))}&$top=${top}`;
10
+ const data = await s4hGet(path);
11
+
12
+ const bins = data.value;
13
+ const total = bins.length;
14
+ const empty = bins.filter(b => b.IsEmpty).length;
15
+ const full = bins.filter(b => b.IsFull).length;
16
+ const occupied = total - empty;
17
+ const blockedPutaway = bins.filter(b => b.PutawayBlock).length;
18
+ const blockedRemoval = bins.filter(b => b.RemovalBlock).length;
19
+
20
+ // Group by storage type
21
+ const byType = {};
22
+ for (const b of bins) {
23
+ const t = b.StorageType;
24
+ if (!byType[t]) byType[t] = { total: 0, empty: 0, occupied: 0 };
25
+ byType[t].total++;
26
+ if (b.IsEmpty) byType[t].empty++;
27
+ else byType[t].occupied++;
28
+ }
29
+
30
+ return {
31
+ warehouse,
32
+ storageType: storageType ?? 'all',
33
+ summary: {
34
+ totalBins: total,
35
+ emptyBins: empty,
36
+ occupiedBins: occupied,
37
+ fullBins: full,
38
+ blockedForPutaway: blockedPutaway,
39
+ blockedForRemoval: blockedRemoval,
40
+ utilizationPct: total > 0 ? Math.round((occupied / total) * 100) : 0
41
+ },
42
+ byStorageType: Object.entries(byType).map(([type, stats]) => ({
43
+ storageType: type,
44
+ ...stats,
45
+ utilizationPct: stats.total > 0 ? Math.round((stats.occupied / stats.total) * 100) : 0
46
+ }))
47
+ };
48
+ }
@@ -0,0 +1,18 @@
1
+ import { s4hPost } from '../lib/s4hClient.js';
2
+
3
+ const BASE = `/sap/opu/odata4/iwbep/all/srvd/sap/zsd_wmmcpservice/0001`;
4
+ const NS = `com.sap.gateway.srvd.zsd_wmmcpservice.v0001`;
5
+
6
+ export async function confirmTransferOrder({ warehouse, transferOrderNumber }) {
7
+ // Instance action — key goes in the URL path, not the body
8
+ const path = `${BASE}/WMTransferOrder(WarehouseNumber='${encodeURIComponent(warehouse)}',TransferOrderNumber='${encodeURIComponent(transferOrderNumber)}')/${NS}.ConfirmTransferOrder`;
9
+
10
+ const data = await s4hPost(path, {});
11
+
12
+ return {
13
+ success: true,
14
+ warehouse,
15
+ transferOrderNumber,
16
+ raw: data
17
+ };
18
+ }
@@ -0,0 +1,23 @@
1
+ import { s4hPost } from '../lib/s4hClient.js';
2
+
3
+ const BASE = `/sap/opu/odata4/iwbep/all/srvd/sap/zsd_wmmcpservice/0001`;
4
+ const NS = `com.sap.gateway.srvd.zsd_wmmcpservice.v0001`;
5
+
6
+ export async function confirmTransferOrderSU({ warehouse, storageUnit }) {
7
+ // Static action — called on the entity set (collection-bound)
8
+ const path = `${BASE}/WMTransferOrder/${NS}.ConfirmTransferOrderSU`;
9
+
10
+ const body = {
11
+ StorageUnit: storageUnit
12
+ };
13
+
14
+ const data = await s4hPost(path, body);
15
+
16
+ return {
17
+ success: true,
18
+ warehouse,
19
+ storageUnit,
20
+ transferOrderNumber: data?.value?.[0]?.TransferOrderNumber ?? data?.TransferOrderNumber ?? null,
21
+ raw: data
22
+ };
23
+ }
@@ -0,0 +1,46 @@
1
+ import { s4hPost, s4hGet } from '../lib/s4hClient.js';
2
+
3
+ const BASE = `/sap/opu/odata4/iwbep/all/srvd/sap/zsd_wmmcpservice/0001`;
4
+ const NS = `com.sap.gateway.srvd.zsd_wmmcpservice.v0001`;
5
+
6
+ export async function createTransferOrder({
7
+ warehouse, movementType, material, plant,
8
+ quantity, unitOfMeasure,
9
+ sourceType = '', sourceBin = '', sourceStorageUnit = '',
10
+ destType, destBin, destStorageUnit = ''
11
+ }) {
12
+ const path = `${BASE}/WMTransferOrder/${NS}.CreateTransferOrder`;
13
+
14
+ const body = {
15
+ WarehouseNumber: warehouse,
16
+ MovementType: movementType,
17
+ Material: material,
18
+ Plant: plant,
19
+ Quantity: quantity,
20
+ UnitOfMeasure: unitOfMeasure,
21
+ SourceStorageType: sourceType,
22
+ SourceBin: sourceBin,
23
+ SourceStorageUnit: sourceStorageUnit,
24
+ DestStorageType: destType,
25
+ DestBin: destBin,
26
+ DestStorageUnit: destStorageUnit
27
+ };
28
+
29
+ const data = await s4hPost(path, body);
30
+
31
+ // RAP static action result does not carry entity fields back — query the latest TO
32
+ let transferOrderNumber = data?.value?.[0]?.TransferOrderNumber ?? data?.TransferOrderNumber ?? null;
33
+ if (!transferOrderNumber) {
34
+ const latest = await s4hGet(
35
+ `${BASE}/WMTransferOrder?$orderby=TransferOrderNumber%20desc&$top=1`
36
+ );
37
+ transferOrderNumber = latest?.value?.[0]?.TransferOrderNumber ?? null;
38
+ }
39
+
40
+ return {
41
+ success: true,
42
+ warehouse,
43
+ transferOrderNumber,
44
+ raw: data
45
+ };
46
+ }
@@ -0,0 +1,30 @@
1
+ import { s4hGet } from '../lib/s4hClient.js';
2
+
3
+ const BASE = `/sap/opu/odata4/iwbep/all/srvd/sap/zsd_wmmcpservice/0001/WMStorageBin`;
4
+
5
+ export async function findEmptyBins({ warehouse, storageType, top = 50 }) {
6
+ const filters = [
7
+ `WarehouseNumber eq '${warehouse}'`,
8
+ `IsEmpty eq true`
9
+ ];
10
+ if (storageType) filters.push(`StorageType eq '${storageType}'`);
11
+
12
+ const path = `${BASE}?$filter=${encodeURIComponent(filters.join(' and '))}&$top=${top}`;
13
+ const data = await s4hGet(path);
14
+
15
+ return {
16
+ count: data.value.length,
17
+ warehouse,
18
+ storageType: storageType ?? 'all',
19
+ emptyBins: data.value.map(b => ({
20
+ bin: b.StorageBin,
21
+ storageType: b.StorageType,
22
+ storageSection: b.StorageSection,
23
+ binType: b.StorageBinType,
24
+ blockedPutaway: b.PutawayBlock,
25
+ blockedRemoval: b.RemovalBlock,
26
+ remainingCapacity: b.RemainingCapacity,
27
+ lastMovement: b.LastMovementDate
28
+ }))
29
+ };
30
+ }
@@ -0,0 +1,33 @@
1
+ import { s4hGet } from '../lib/s4hClient.js';
2
+
3
+ const BASE = `/sap/opu/odata4/iwbep/all/srvd/sap/zsd_wmmcpservice/0001/WMWarehouseStock`;
4
+
5
+ export async function getStockForMaterial({ warehouse, material, storageType, top = 20 }) {
6
+ const filters = [`WarehouseNumber eq '${warehouse}'`];
7
+ if (material) filters.push(`Material eq '${material}'`);
8
+ if (storageType) filters.push(`StorageType eq '${storageType}'`);
9
+
10
+ const path = `${BASE}?$filter=${encodeURIComponent(filters.join(' and '))}&$top=${top}`;
11
+ const data = await s4hGet(path);
12
+
13
+ return {
14
+ count: data.value.length,
15
+ warehouse,
16
+ material: material ?? 'all',
17
+ stock: data.value.map(q => ({
18
+ bin: q.StorageBin,
19
+ storageType: q.StorageType,
20
+ quantNumber: q.QuantNumber,
21
+ material: q.Material,
22
+ plant: q.Plant,
23
+ batch: q.Batch,
24
+ totalStock: q.TotalStock,
25
+ availableStock: q.AvailableStock,
26
+ pickQuantity: q.PickQuantity,
27
+ transferQuantity: q.TransferQuantity,
28
+ uom: q.UnitOfMeasure,
29
+ stockCategory: q.StockCategory,
30
+ lastMovement: q.LastMovementDate
31
+ }))
32
+ };
33
+ }