Add separate read-only ERP lager MCP

This commit is contained in:
gitea_admin committed 2026-09-30 10:51:03 +02:00
1 parent b0bf8753ed
commit 1b7e0c8a13
5 files changed
+234

No files matched your search

+3
View File
@@ -42,6 +42,9 @@ Bestand, Charge, MHD und Bewegungen.
## Technisch umgesetzt ## Technisch umgesetzt
- `modules/erp/lager/service.php` - `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. 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.
@@ -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 <MARKET_MCP_TOKEN>`
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.
+91
View File
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/service.php';
function erp_lager_mcp_token(array $env): string
{
$token = env_value('MARKET_MCP_TOKEN', $env);
if ($token === '') {
throw new RuntimeException('MARKET_MCP_TOKEN ist nicht konfiguriert.');
}
return $token;
}
function erp_lager_mcp_authorize(array $env): string
{
$header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (!preg_match('/^Bearer\s+(.+)$/i', $header, $matches)) {
throw new RuntimeException('MCP Authorization Bearer Token fehlt.');
}
$token = trim($matches[1]);
if (!hash_equals(erp_lager_mcp_token($env), $token)) {
throw new RuntimeException('MCP Authorization Bearer Token ist ungültig.');
}
return 'hermes';
}
function erp_lager_mcp_tools(): array
{
return [
[
'name' => '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),
};
}
+88
View File
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../modules/shared/db.php';
require_once __DIR__ . '/../../modules/erp/lager/mcp.php';
final class ErpLagerMcpJsonRpcException extends RuntimeException
{
public function __construct(private int $rpcCode, string $message, ?Throwable $previous = null)
{
parent::__construct($message, 0, $previous);
}
public function rpcCode(): int
{
return $this->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);
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../modules/erp/lager/mcp.php';
function assert_test(bool $condition, string $message): void
{
if (!$condition) {
fwrite(STDERR, $message . PHP_EOL);
exit(1);
}
}
$tools = erp_lager_mcp_tools();
assert_test(count($tools) === 2, 'Expected 2 ERP lager MCP tools, got ' . count($tools));
$names = array_map(static fn (array $tool): string => $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");