From 1b7e0c8a13b0e10f7994782551242b575bffdf0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Gla=CC=88ser?= Date: Wed, 30 Sep 2026 10:51:03 +0200 Subject: [PATCH] Add separate read-only ERP lager MCP --- docs/modules/erp/lager/README.md | 3 + .../konzepte/erp_lager_hermes_mcp_api.md | 29 ++++++ modules/erp/lager/mcp.php | 91 +++++++++++++++++++ public/api/erp-lager-mcp.php | 88 ++++++++++++++++++ .../tests/erp_lager_mcp_tools_list_test.php | 23 +++++ 5 files changed, 234 insertions(+) create mode 100644 docs/modules/erp/lager/konzepte/erp_lager_hermes_mcp_api.md create mode 100644 modules/erp/lager/mcp.php create mode 100644 public/api/erp-lager-mcp.php create mode 100644 scripts/tests/erp_lager_mcp_tools_list_test.php diff --git a/docs/modules/erp/lager/README.md b/docs/modules/erp/lager/README.md index e3c402b..ff97156 100644 --- a/docs/modules/erp/lager/README.md +++ b/docs/modules/erp/lager/README.md @@ -42,6 +42,9 @@ Bestand, Charge, MHD und Bewegungen. ## Technisch umgesetzt - `modules/erp/lager/service.php` +- Read-only MCP für Hermes: `modules/erp/lager/mcp.php` +- MCP-Endpoint: `public/api/erp-lager-mcp.php` +- [ERP-Lager-Hermes-MCP-API](konzepte/erp_lager_hermes_mcp_api.md) Hersteller-Stammdaten gehören zum Modul `kontakte`. Eine reale Lagercharge kann dort einen Hersteller und dessen Hersteller-Chargennummer referenzieren; die interne Naurua-Chargennummer bleibt Eigentum des Moduls `lager`. Virtuelle Fehlbestandschargen haben keinen Herstellerbezug. diff --git a/docs/modules/erp/lager/konzepte/erp_lager_hermes_mcp_api.md b/docs/modules/erp/lager/konzepte/erp_lager_hermes_mcp_api.md new file mode 100644 index 0000000..90dc25c --- /dev/null +++ b/docs/modules/erp/lager/konzepte/erp_lager_hermes_mcp_api.md @@ -0,0 +1,29 @@ +# Hermes: ERP-Lager-MCP-API + +## Zweck + +Das ERP-Lager stellt ein eigenes, read-only MCP-Interface bereit. Hermes kann dadurch ausschließlich die Lager-Tools laden, ohne Marktmanagement-Tools in seinen Kontext aufzunehmen. + +Endpoint: `https://erpnaurua.imhochrain.ch/api/erp-lager-mcp.php` + +Authentifizierung: `Authorization: Bearer ` + +Der Token ist identisch mit dem Marktmanagement-MCP, weil derselbe Hermes-Bot auf beide fachlich getrennten Interfaces zugreift. Die Toolsets und Endpoints bleiben trotzdem getrennt. + +## Verfügbare Tools + +### `erp_lager.list_products` + +Liefert alle aktiven Produkte mit ihren stabilen IDs. + +### `erp_lager.get_product` + +Liest alle Lagerdaten eines aktiven Produkts. Eingabe: `{ "product_id": 12 }`. + +Die Antwort enthält die GUI-parallele Produktübersicht mit Produktstammdaten, aktueller und verfügbarer Lagermenge, Menge im Wareneingang, aktueller Charge, Ausverkaufsdatum und Forecast-Status, nächster aktiver offener Charge, weiteren offenen zukünftigen Chargen, historischen geschlossenen Chargen sowie virtueller Fehlbestandsmenge und virtueller Fehlbestandscharge. + +Das Interface ist ausschließlich lesend. Es gibt keine Lager-MCP-Tools zum Anlegen, Ändern, Buchen, Verschieben oder Löschen. + +## JSON-RPC + +Das Interface unterstützt `initialize`, `notifications/initialized`, `tools/list` und `tools/call` gemäß dem bestehenden Marktmanagement-MCP-Transport. diff --git a/modules/erp/lager/mcp.php b/modules/erp/lager/mcp.php new file mode 100644 index 0000000..d10fc30 --- /dev/null +++ b/modules/erp/lager/mcp.php @@ -0,0 +1,91 @@ + 'erp_lager.list_products', + 'description' => 'Aktive ERP-Lagerprodukte zur Ermittlung der Produkt-IDs lesen', + 'inputSchema' => ['type' => 'object', 'properties' => []], + ], + [ + 'name' => 'erp_lager.get_product', + 'description' => 'Produkt, aktuelle Lagermenge, Chargen und Ausverkaufsprognose lesen', + 'inputSchema' => [ + 'type' => 'object', + 'required' => ['product_id'], + 'properties' => [ + 'product_id' => ['type' => 'integer'], + ], + ], + ], + ]; +} + +function erp_lager_mcp_list_products(PDO $pdo): array +{ + return array_map( + static fn (array $product): array => [ + 'id' => $product['id'], + 'name' => $product['name'], + 'sku' => $product['sku'], + 'uom' => $product['uom'], + ], + get_otc_order_form_products($pdo) + ); +} + +function erp_lager_mcp_get_product(PDO $pdo, int $productId): array +{ + if ($productId < 1) { + throw new InvalidArgumentException('product_id muss eine positive Ganzzahl sein.'); + } + + foreach (get_lager_product_overview($pdo) as $product) { + if ((int) $product['id'] === $productId) { + return $product; + } + } + + throw new InvalidArgumentException('Aktives Lagerprodukt nicht gefunden.'); +} + +function erp_lager_mcp_call(PDO $pdo, string $tool, array $arguments): array +{ + return match ($tool) { + 'erp_lager.list_products' => erp_lager_mcp_list_products($pdo), + 'erp_lager.get_product' => erp_lager_mcp_get_product( + $pdo, + (int) ($arguments['product_id'] ?? 0) + ), + default => throw new InvalidArgumentException('Unbekanntes Lager-MCP-Tool: ' . $tool), + }; +} diff --git a/public/api/erp-lager-mcp.php b/public/api/erp-lager-mcp.php new file mode 100644 index 0000000..f04d06f --- /dev/null +++ b/public/api/erp-lager-mcp.php @@ -0,0 +1,88 @@ +rpcCode; + } +} + +header('Content-Type: application/json; charset=utf-8'); +header('Cache-Control: no-store'); + +$env = expand_env_values(parse_env_file(__DIR__ . '/../../.env')); +$request = []; + +try { + erp_lager_mcp_authorize($env); + $pdo = connect_database($env); + $body = file_get_contents('php://input'); + $request = json_decode($body === false ? '' : $body, true, 512, JSON_THROW_ON_ERROR); + if (!is_array($request) || ($request['jsonrpc'] ?? '') !== '2.0') { + throw new InvalidArgumentException('Ungültige JSON-RPC-Anfrage.'); + } + + if (array_key_exists('params', $request) && !is_array($request['params'])) { + throw new ErpLagerMcpJsonRpcException(-32602, 'Parameter müssen als Objekt oder Array übergeben werden.'); + } + + $id = $request['id'] ?? null; + $method = (string) ($request['method'] ?? ''); + $params = is_array($request['params'] ?? null) ? $request['params'] : []; + + if ($method === 'initialize') { + $result = [ + 'protocolVersion' => (string) ($params['protocolVersion'] ?? '2025-03-26'), + 'capabilities' => ['tools' => ['listChanged' => false]], + 'serverInfo' => ['name' => 'naurua-erp-lager', 'version' => '1.0.0'], + ]; + } elseif ($method === 'notifications/initialized') { + http_response_code(202); + exit; + } elseif ($method === 'tools/list') { + $result = ['tools' => erp_lager_mcp_tools()]; + } elseif ($method === 'tools/call') { + $tool = (string) ($params['name'] ?? ''); + $arguments = is_array($params['arguments'] ?? null) ? $params['arguments'] : []; + if (array_key_exists('arguments', $params) && !is_array($params['arguments'])) { + throw new ErpLagerMcpJsonRpcException(-32602, 'Tool-Argumente müssen als Objekt übergeben werden.'); + } + try { + $result = [ + 'content' => [[ + 'type' => 'text', + 'text' => json_encode(erp_lager_mcp_call($pdo, $tool, $arguments), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), + ]], + 'isError' => false, + ]; + } catch (InvalidArgumentException $e) { + throw new ErpLagerMcpJsonRpcException(-32602, $e->getMessage(), $e); + } + } else { + throw new ErpLagerMcpJsonRpcException(-32601, 'Unbekannte JSON-RPC-Methode: ' . $method); + } + + echo json_encode(['jsonrpc' => '2.0', 'id' => $id, 'result' => $result], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); +} catch (Throwable $e) { + $isAuthError = $e instanceof RuntimeException && str_contains($e->getMessage(), 'Token'); + $isJsonRpcError = $e instanceof ErpLagerMcpJsonRpcException; + http_response_code($isJsonRpcError ? 200 : ($isAuthError ? 401 : 400)); + echo json_encode([ + 'jsonrpc' => '2.0', + 'id' => $request['id'] ?? null, + 'error' => [ + 'code' => $isJsonRpcError ? $e->rpcCode() : -32600, + 'message' => $e->getMessage(), + ], + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); +} diff --git a/scripts/tests/erp_lager_mcp_tools_list_test.php b/scripts/tests/erp_lager_mcp_tools_list_test.php new file mode 100644 index 0000000..a3e5154 --- /dev/null +++ b/scripts/tests/erp_lager_mcp_tools_list_test.php @@ -0,0 +1,23 @@ + $tool['name'], $tools); +assert_test($names === ['erp_lager.list_products', 'erp_lager.get_product'], 'Unexpected ERP lager MCP tool names'); + +$productTool = $tools[1]; +assert_test($productTool['inputSchema']['required'] === ['product_id'], 'Product detail tool must require product_id'); +assert_test($productTool['inputSchema']['properties']['product_id']['type'] === 'integer', 'Product ID must be an integer'); +fwrite(STDOUT, "erp_lager_mcp_tools_list_test: OK\n");