Add separate read-only ERP lager MCP
This commit is contained in:
1 parent
b0bf8753ed
commit
1b7e0c8a13
5 files changed
+234
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
Reference in new issue
Block a user