diff --git a/docs/modules/market_management/konzepte/market_management_hermes_mcp_konzept.md b/docs/modules/market_management/konzepte/market_management_hermes_mcp_konzept.md index 9ce4a2d..c2c5ed2 100644 --- a/docs/modules/market_management/konzepte/market_management_hermes_mcp_konzept.md +++ b/docs/modules/market_management/konzepte/market_management_hermes_mcp_konzept.md @@ -47,12 +47,16 @@ OTC-Verkäufen abgeleitet. ## 4. Authentifizierung und Autorisierung - MCP-Endpunkt über geschütztes HTTPS -- OAuth-/OIDC-basierte Benutzerautorisierung -- Token mit Benutzeridentität, Zielgruppe und Modul-Scope -- getrennte Scopes für Lesen, Schreiben und Archivieren -- Prüfung an der Transportgrenze +- Bearer-Token-Prüfung an der Transportgrenze +- der konfigurierte Token identifiziert den technischen Hermes-Benutzerkontext - zusätzliche Prüfung im jeweiligen Tool-Handler -- keine langlebigen gemeinsamen Zugangsdaten +- kein direkter Datenbankzugriff für Hermes + +Für die erste Implementierungsstufe wird bewusst keine neue OAuth-/OIDC- oder +Entitlement-Infrastruktur eingeführt. Der Token wird ausschließlich über die +geschützte App-Konfiguration bereitgestellt und mit konstanter Zeit verglichen. +Die Implementierung verwendet dafür den Konfigurationswert +`MARKET_MCP_TOKEN`. Die fachliche Ownership der Lizenz-/Entitlement-Daten bleibt offen. Die Schnittstelle prüft nur die über den bestehenden Zugriffskontext gelieferte diff --git a/modules/market_management/mcp.php b/modules/market_management/mcp.php new file mode 100644 index 0000000..f98cfd2 --- /dev/null +++ b/modules/market_management/mcp.php @@ -0,0 +1,111 @@ + 'market_management.search_markets', 'description' => 'Märkte suchen', 'inputSchema' => ['type' => 'object']], + ['name' => 'market_management.get_market', 'description' => 'Markt mit Durchführungen lesen', 'inputSchema' => ['type' => 'object', 'required' => ['market_id']]], + ['name' => 'market_management.create_market', 'description' => 'Markt mit bestehender Kategorie anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['name', 'category_id']]], + ['name' => 'market_management.update_market', 'description' => 'Markt ändern', 'inputSchema' => ['type' => 'object', 'required' => ['market_id']]], + ['name' => 'market_management.archive_market', 'description' => 'Markt archivieren', 'inputSchema' => ['type' => 'object', 'required' => ['market_id']]], + ['name' => 'market_management.reactivate_market', 'description' => 'Markt reaktivieren', 'inputSchema' => ['type' => 'object', 'required' => ['market_id']]], + ['name' => 'market_management.list_categories', 'description' => 'Bestehende Kategorien lesen', 'inputSchema' => ['type' => 'object']], + ['name' => 'market_management.create_occurrence', 'description' => 'Durchführung anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['market_definition_id']]], + ['name' => 'market_management.get_occurrence', 'description' => 'Durchführung mit Tagen lesen', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id']]], + ['name' => 'market_management.update_occurrence', 'description' => 'Durchführung ändern', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id']]], + ['name' => 'market_management.archive_occurrence', 'description' => 'Durchführung archivieren', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id']]], + ['name' => 'market_management.reactivate_occurrence', 'description' => 'Durchführung reaktivieren', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id']]], + ['name' => 'market_management.create_day', 'description' => 'Durchführungstag anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id', 'date']]], + ['name' => 'market_management.update_day', 'description' => 'Durchführungstag ändern', 'inputSchema' => ['type' => 'object', 'required' => ['day_id']]], + ['name' => 'market_management.delete_day', 'description' => 'Durchführungstag entfernen', 'inputSchema' => ['type' => 'object', 'required' => ['day_id']]], + ]; +} + +function market_management_mcp_call(PDO $pdo, string $tool, array $arguments): array +{ + return match ($tool) { + 'market_management.search_markets' => market_management_search_markets($pdo, $arguments), + 'market_management.get_market' => market_management_get_market( + $pdo, + market_management_require_positive_id($arguments['market_id'] ?? null, 'market_id'), + (bool) ($arguments['include_archived'] ?? false) + ) ?? throw new RuntimeException('Markt nicht gefunden.'), + 'market_management.create_market' => market_management_create_market($pdo, $arguments), + 'market_management.update_market' => market_management_update_market( + $pdo, + market_management_require_positive_id($arguments['market_id'] ?? null, 'market_id'), + $arguments + ), + 'market_management.archive_market' => market_management_set_market_archive( + $pdo, + market_management_require_positive_id($arguments['market_id'] ?? null, 'market_id'), + true + ), + 'market_management.reactivate_market' => market_management_set_market_archive( + $pdo, + market_management_require_positive_id($arguments['market_id'] ?? null, 'market_id'), + false + ), + 'market_management.list_categories' => market_management_list_categories($pdo), + 'market_management.create_occurrence' => market_management_create_occurrence($pdo, $arguments), + 'market_management.get_occurrence' => market_management_get_occurrence( + $pdo, + market_management_require_positive_id($arguments['occurrence_id'] ?? null, 'occurrence_id'), + (bool) ($arguments['include_archived'] ?? false) + ) ?? throw new RuntimeException('Durchführung nicht gefunden.'), + 'market_management.update_occurrence' => market_management_update_occurrence( + $pdo, + market_management_require_positive_id($arguments['occurrence_id'] ?? null, 'occurrence_id'), + $arguments + ), + 'market_management.archive_occurrence' => market_management_set_occurrence_archive( + $pdo, + market_management_require_positive_id($arguments['occurrence_id'] ?? null, 'occurrence_id'), + true + ), + 'market_management.reactivate_occurrence' => market_management_set_occurrence_archive( + $pdo, + market_management_require_positive_id($arguments['occurrence_id'] ?? null, 'occurrence_id'), + false + ), + 'market_management.create_day' => market_management_create_day($pdo, $arguments), + 'market_management.update_day' => market_management_update_day( + $pdo, + market_management_require_positive_id($arguments['day_id'] ?? null, 'day_id'), + $arguments + ), + 'market_management.delete_day' => market_management_delete_day( + $pdo, + market_management_require_positive_id($arguments['day_id'] ?? null, 'day_id') + ), + default => throw new InvalidArgumentException('Unbekanntes MCP-Tool: ' . $tool), + }; +} diff --git a/modules/market_management/service.php b/modules/market_management/service.php new file mode 100644 index 0000000..63fc9d2 --- /dev/null +++ b/modules/market_management/service.php @@ -0,0 +1,444 @@ +prepare('SELECT 1 FROM market_category WHERE id = :id AND is_active = TRUE'); + $stmt->execute([':id' => $categoryId]); + return $stmt->fetchColumn() !== false; +} + +function market_management_create_market(PDO $pdo, array $input): array +{ + $name = trim((string) ($input['name'] ?? '')); + $categoryId = market_management_require_positive_id($input['category_id'] ?? null, 'category_id'); + + if ($name === '') { + throw new InvalidArgumentException('name ist erforderlich.'); + } + if (!market_management_category_exists($pdo, $categoryId)) { + throw new InvalidArgumentException('category_id verweist nicht auf eine aktive Kategorie.'); + } + + $stmt = $pdo->prepare( + 'INSERT INTO market_definition (name, category_id) + VALUES (:name, :category_id) + RETURNING id, name, category_id, archived_at, created_at, updated_at' + ); + $stmt->execute([':name' => $name, ':category_id' => $categoryId]); + return (array) $stmt->fetch(); +} + +function market_management_search_markets(PDO $pdo, array $input): array +{ + $search = market_management_optional_string($input, 'search'); + $includeArchived = (bool) ($input['include_archived'] ?? false); + $limit = min(100, max(1, (int) ($input['limit'] ?? 50))); + + $where = []; + $params = [':limit' => $limit]; + if (!$includeArchived) { + $where[] = 'm.archived_at IS NULL'; + } + if ($search !== null) { + $where[] = 'm.name ILIKE :search'; + $params[':search'] = '%' . $search . '%'; + } + + $stmt = $pdo->prepare( + 'SELECT m.id, m.name, m.category_id, c.code AS category_code, c.name AS category_name, + m.archived_at, m.created_at, m.updated_at + FROM market_definition m + INNER JOIN market_category c ON c.id = m.category_id + WHERE ' . ($where === [] ? 'TRUE' : implode(' AND ', $where)) . ' + ORDER BY m.name ASC, m.id ASC + LIMIT :limit' + ); + foreach ($params as $key => $value) { + $stmt->bindValue($key, $value, $key === ':limit' ? PDO::PARAM_INT : PDO::PARAM_STR); + } + $stmt->execute(); + return $stmt->fetchAll(); +} + +function market_management_get_market(PDO $pdo, int $marketId, bool $includeArchived = false): ?array +{ + $stmt = $pdo->prepare( + 'SELECT m.id, m.name, m.category_id, c.code AS category_code, c.name AS category_name, + m.archived_at, m.created_at, m.updated_at + FROM market_definition m + INNER JOIN market_category c ON c.id = m.category_id + WHERE m.id = :id AND (:include_archived OR m.archived_at IS NULL)' + ); + $stmt->execute([':id' => $marketId, ':include_archived' => $includeArchived]); + $market = $stmt->fetch(); + if (!is_array($market)) { + return null; + } + + $occurrences = $pdo->prepare( + 'SELECT id, status, place, address, municipality_reference_id, notes, + registration_deadline, request_or_registration_date, confirmation_date, + payment_deadline, payment_status, payment_date, paid_amount, + contract_folder_url, archived_at, created_at, updated_at + FROM market_occurrence + WHERE market_definition_id = :market_id + AND (:include_archived OR archived_at IS NULL) + ORDER BY id ASC' + ); + $occurrences->execute([':market_id' => $marketId, ':include_archived' => $includeArchived]); + $market['occurrences'] = $occurrences->fetchAll(); + return $market; +} + +function market_management_update_market(PDO $pdo, int $marketId, array $input): array +{ + $sets = []; + $params = [':id' => $marketId]; + if (array_key_exists('name', $input)) { + $name = trim((string) $input['name']); + if ($name === '') { + throw new InvalidArgumentException('name darf nicht leer sein.'); + } + $sets[] = 'name = :name'; + $params[':name'] = $name; + } + if (array_key_exists('category_id', $input)) { + $categoryId = market_management_require_positive_id($input['category_id'], 'category_id'); + if (!market_management_category_exists($pdo, $categoryId)) { + throw new InvalidArgumentException('category_id verweist nicht auf eine aktive Kategorie.'); + } + $sets[] = 'category_id = :category_id'; + $params[':category_id'] = $categoryId; + } + if ($sets === []) { + throw new InvalidArgumentException('Mindestens ein änderbares Feld ist erforderlich.'); + } + + $sets[] = 'updated_at = NOW()'; + $stmt = $pdo->prepare( + 'UPDATE market_definition SET ' . implode(', ', $sets) . ' WHERE id = :id + RETURNING id, name, category_id, archived_at, created_at, updated_at' + ); + $stmt->execute($params); + $market = $stmt->fetch(); + if (!is_array($market)) { + throw new RuntimeException('Markt nicht gefunden.'); + } + return $market; +} + +function market_management_set_market_archive(PDO $pdo, int $marketId, bool $archive): array +{ + $stmt = $pdo->prepare( + 'UPDATE market_definition + SET archived_at = ' . ($archive ? 'COALESCE(archived_at, NOW())' : 'NULL') . ', updated_at = NOW() + WHERE id = :id + RETURNING id, name, category_id, archived_at, created_at, updated_at' + ); + $stmt->execute([':id' => $marketId]); + $market = $stmt->fetch(); + if (!is_array($market)) { + throw new RuntimeException('Markt nicht gefunden.'); + } + return $market; +} + +function market_management_list_categories(PDO $pdo): array +{ + return $pdo->query( + 'SELECT id, code, name FROM market_category WHERE is_active = TRUE ORDER BY name ASC, id ASC' + )->fetchAll(); +} + +function market_management_validate_status(mixed $status): string +{ + $value = trim((string) $status); + if (!in_array($value, MARKET_MANAGEMENT_STATUSES, true)) { + throw new InvalidArgumentException('Ungültiger Durchführungsstatus.'); + } + return $value; +} + +function market_management_write_fields(array $input, array $definitions): array +{ + $fields = []; + $params = []; + foreach ($definitions as $field => $type) { + if (!array_key_exists($field, $input)) { + continue; + } + $value = match ($type) { + 'string' => market_management_optional_string($input, $field), + 'bool' => market_management_optional_bool($input, $field), + 'number' => market_management_optional_number($input, $field), + 'integer' => market_management_optional_integer($input, $field), + 'id' => market_management_require_positive_id($input[$field], $field), + default => throw new LogicException('Unbekannter Feldtyp.'), + }; + $fields[] = $field . ' = :' . $field; + $params[':' . $field] = $value; + } + return [$fields, $params]; +} + +function market_management_create_occurrence(PDO $pdo, array $input): array +{ + $marketId = market_management_require_positive_id($input['market_definition_id'] ?? null, 'market_definition_id'); + if (market_management_get_market($pdo, $marketId, true) === null) { + throw new InvalidArgumentException('market_definition_id verweist nicht auf einen Markt.'); + } + $status = array_key_exists('status', $input) ? market_management_validate_status($input['status']) : 'open'; + $definitions = [ + 'place' => 'string', 'address' => 'string', 'municipality_reference_id' => 'id', 'notes' => 'string', + 'registration_deadline' => 'string', 'request_or_registration_date' => 'string', 'confirmation_date' => 'string', + 'payment_deadline' => 'string', 'stand_size' => 'string', 'table_available' => 'bool', + 'table_rentable' => 'bool', 'own_table_required' => 'bool', 'shelter_available' => 'bool', + 'power_available' => 'bool', 'water_available' => 'bool', 'lighting_available' => 'bool', + 'other_infrastructure' => 'string', 'own_stand_requirements' => 'string', 'travel_address' => 'string', + 'travel_distance_km' => 'number', 'travel_duration_minutes' => 'integer', 'parking_options' => 'string', + 'delivery_access' => 'string', 'direct_unload_possible' => 'bool', 'storage_options' => 'string', + 'travel_notes' => 'string', 'payment_status' => 'string', 'payment_date' => 'string', + 'paid_amount' => 'number', 'payment_method_code' => 'string', 'cancellation_terms' => 'string', + 'revenue_share_terms' => 'string', 'other_participation_conditions' => 'string', 'contract_folder_url' => 'string', + ]; + [$fields, $params] = market_management_write_fields($input, $definitions); + if (isset($params[':payment_status']) && !in_array($params[':payment_status'], ['paid', 'unpaid'], true)) { + throw new InvalidArgumentException('Ungültiger Zahlungsstatus.'); + } + array_unshift($fields, 'market_definition_id = :market_definition_id', 'status = :status'); + $params[':market_definition_id'] = $marketId; + $params[':status'] = $status; + $columns = array_map(static fn (string $field): string => explode(' = ', $field, 2)[0], $fields); + $values = array_map(static fn (string $field): string => explode(' = ', $field, 2)[1], $fields); + $stmt = $pdo->prepare( + 'INSERT INTO market_occurrence (' . implode(', ', $columns) . ') + VALUES (' . implode(', ', $values) . ') + RETURNING *' + ); + $stmt->execute($params); + return (array) $stmt->fetch(); +} + +function market_management_get_occurrence(PDO $pdo, int $occurrenceId, bool $includeArchived = false): ?array +{ + $stmt = $pdo->prepare( + 'SELECT * FROM market_occurrence + WHERE id = :id AND (:include_archived OR archived_at IS NULL)' + ); + $stmt->execute([':id' => $occurrenceId, ':include_archived' => $includeArchived]); + $occurrence = $stmt->fetch(); + if (!is_array($occurrence)) { + return null; + } + $days = $pdo->prepare( + 'SELECT * FROM market_occurrence_day + WHERE occurrence_id = :occurrence_id ORDER BY date ASC, id ASC' + ); + $days->execute([':occurrence_id' => $occurrenceId]); + $occurrence['days'] = $days->fetchAll(); + return $occurrence; +} + +function market_management_update_occurrence(PDO $pdo, int $occurrenceId, array $input): array +{ + $definitions = [ + 'status' => 'string', 'place' => 'string', 'address' => 'string', 'municipality_reference_id' => 'id', + 'notes' => 'string', 'registration_deadline' => 'string', 'request_or_registration_date' => 'string', + 'confirmation_date' => 'string', 'payment_deadline' => 'string', 'stand_size' => 'string', + 'table_available' => 'bool', 'table_rentable' => 'bool', 'own_table_required' => 'bool', + 'shelter_available' => 'bool', 'power_available' => 'bool', 'water_available' => 'bool', + 'lighting_available' => 'bool', 'other_infrastructure' => 'string', 'own_stand_requirements' => 'string', + 'travel_address' => 'string', 'travel_distance_km' => 'number', 'travel_duration_minutes' => 'integer', + 'parking_options' => 'string', 'delivery_access' => 'string', 'direct_unload_possible' => 'bool', + 'storage_options' => 'string', 'travel_notes' => 'string', 'payment_status' => 'string', + 'payment_date' => 'string', 'paid_amount' => 'number', 'payment_method_code' => 'string', + 'cancellation_terms' => 'string', 'revenue_share_terms' => 'string', + 'other_participation_conditions' => 'string', 'contract_folder_url' => 'string', + ]; + [$fields, $params] = market_management_write_fields($input, $definitions); + if (array_key_exists('status', $input)) { + $params[':status'] = market_management_validate_status($input['status']); + } + if (isset($params[':payment_status']) && !in_array($params[':payment_status'], ['paid', 'unpaid'], true)) { + throw new InvalidArgumentException('Ungültiger Zahlungsstatus.'); + } + if ($fields === []) { + throw new InvalidArgumentException('Mindestens ein änderbares Feld ist erforderlich.'); + } + $fields[] = 'updated_at = NOW()'; + $params[':id'] = $occurrenceId; + $stmt = $pdo->prepare( + 'UPDATE market_occurrence SET ' . implode(', ', $fields) . ' WHERE id = :id RETURNING *' + ); + $stmt->execute($params); + $occurrence = $stmt->fetch(); + if (!is_array($occurrence)) { + throw new RuntimeException('Durchführung nicht gefunden.'); + } + return $occurrence; +} + +function market_management_create_day(PDO $pdo, array $input): array +{ + $occurrenceId = market_management_require_positive_id($input['occurrence_id'] ?? null, 'occurrence_id'); + if (market_management_get_occurrence($pdo, $occurrenceId, true) === null) { + throw new InvalidArgumentException('occurrence_id verweist nicht auf eine Durchführung.'); + } + $date = trim((string) ($input['date'] ?? '')); + if ($date === '') { + throw new InvalidArgumentException('date ist erforderlich.'); + } + $definitions = [ + 'stand_start_time' => 'string', 'stand_end_time' => 'string', 'setup_start_time' => 'string', + 'setup_end_time' => 'string', 'delivery_start_time' => 'string', 'delivery_end_time' => 'string', + 'observations' => 'string', + ]; + [$fields, $params] = market_management_write_fields($input, $definitions); + array_unshift($fields, 'occurrence_id = :occurrence_id', 'date = :date'); + $params[':occurrence_id'] = $occurrenceId; + $params[':date'] = $date; + $columns = array_map(static fn (string $field): string => explode(' = ', $field, 2)[0], $fields); + $values = array_map(static fn (string $field): string => explode(' = ', $field, 2)[1], $fields); + $stmt = $pdo->prepare( + 'INSERT INTO market_occurrence_day (' . implode(', ', $columns) . ') + VALUES (' . implode(', ', $values) . ') RETURNING *' + ); + $stmt->execute($params); + return (array) $stmt->fetch(); +} + +function market_management_update_day(PDO $pdo, int $dayId, array $input): array +{ + $definitions = [ + 'date' => 'string', 'stand_start_time' => 'string', 'stand_end_time' => 'string', + 'setup_start_time' => 'string', 'setup_end_time' => 'string', 'delivery_start_time' => 'string', + 'delivery_end_time' => 'string', 'observations' => 'string', + ]; + [$fields, $params] = market_management_write_fields($input, $definitions); + if ($fields === []) { + throw new InvalidArgumentException('Mindestens ein änderbares Feld ist erforderlich.'); + } + $fields[] = 'updated_at = NOW()'; + $params[':id'] = $dayId; + $stmt = $pdo->prepare( + 'UPDATE market_occurrence_day SET ' . implode(', ', $fields) . ' WHERE id = :id RETURNING *' + ); + $stmt->execute($params); + $day = $stmt->fetch(); + if (!is_array($day)) { + throw new RuntimeException('Durchführungstag nicht gefunden.'); + } + return $day; +} + +function market_management_set_occurrence_archive(PDO $pdo, int $occurrenceId, bool $archive): array +{ + $stmt = $pdo->prepare( + 'UPDATE market_occurrence + SET archived_at = ' . ($archive ? 'COALESCE(archived_at, NOW())' : 'NULL') . ', updated_at = NOW() + WHERE id = :id RETURNING *' + ); + $stmt->execute([':id' => $occurrenceId]); + $occurrence = $stmt->fetch(); + if (!is_array($occurrence)) { + throw new RuntimeException('Durchführung nicht gefunden.'); + } + return $occurrence; +} + +function market_management_delete_day(PDO $pdo, int $dayId): array +{ + $stmt = $pdo->prepare('DELETE FROM market_occurrence_day WHERE id = :id RETURNING id, occurrence_id, date'); + $stmt->execute([':id' => $dayId]); + $day = $stmt->fetch(); + if (!is_array($day)) { + throw new RuntimeException('Durchführungstag nicht gefunden.'); + } + return $day; +} diff --git a/public/api/market-management-mcp.php b/public/api/market-management-mcp.php new file mode 100644 index 0000000..70e61de --- /dev/null +++ b/public/api/market-management-mcp.php @@ -0,0 +1,58 @@ + (string) ($params['protocolVersion'] ?? '2025-03-26'), + 'capabilities' => ['tools' => ['listChanged' => false]], + 'serverInfo' => ['name' => 'naurua-market-management', 'version' => '1.0.0'], + ]; + } elseif ($method === 'notifications/initialized') { + http_response_code(202); + exit; + } elseif ($method === 'tools/list') { + $result = ['tools' => market_management_mcp_tools()]; + } elseif ($method === 'tools/call') { + $tool = (string) ($params['name'] ?? ''); + $arguments = is_array($params['arguments'] ?? null) ? $params['arguments'] : []; + $result = [ + 'content' => [[ + 'type' => 'text', + 'text' => json_encode(market_management_mcp_call($pdo, $tool, $arguments), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), + ]], + 'isError' => false, + ]; + } else { + throw new InvalidArgumentException('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) { + http_response_code($e instanceof RuntimeException && str_contains($e->getMessage(), 'Token') ? 401 : 400); + echo json_encode([ + 'jsonrpc' => '2.0', + 'id' => $request['id'] ?? null, + 'error' => ['code' => -32600, 'message' => $e->getMessage()], + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); +}