diff --git a/.env b/.env index ebdecfb0..7f0975c9 100644 --- a/.env +++ b/.env @@ -85,7 +85,7 @@ APP_CMS_API_BASE_URL="https://www.ep-reisen.de/" # TYPO3 API key (X-Api-Key), sent by the typo3.client scoped client and by app:content:sync; # the real value per stage goes in .env.local APP_CMS_API_KEY= -# Bearer tokens for the chatbot MCP endpoint (/mcp), one per client: "name:token,name:token" +# Bearer tokens for the chatbot MCP endpoints (/mcp, /mcp/trips), one per client: "name:token,name:token" # (e.g. "n8n:…,claude-desktop:…"); empty refuses every request APP_MCP_TOKENS= APP_WEBSITE_BASE_URL="https://www.ep-reisen.de/" diff --git a/config/packages/mcp.yaml b/config/packages/mcp.yaml index b0c18f7b..30ce9e88 100644 --- a/config/packages/mcp.yaml +++ b/config/packages/mcp.yaml @@ -14,3 +14,17 @@ mcp: allowed_hosts: false registry: tools: ['App\Chatbot\Mcp\Tool\'] + # The same trip tools without the website's editorial text: no search_content and + # get_document, and a get_trip that leaves out the product and hotel page texts. + # Same tokens as /mcp; a client picks the variant by the path it is pointed at. + chatbot_trips: + name: 'MyE&P chatbot (trips only)' + version: '1.0.0' + instructions: 'Answers questions about the trips of E&P Reisen and its brands: dates, rooms, prices, availability, pickups and inclusions, all from the booking system. Every result carries a url to cite. Prices and availability are a snapshot as of dataAsOf; the booking page is binding. Website texts such as descriptions, FAQs and terms are not available here: for anything the tools do not answer, refer the user to the url of the trip.' + http: + path: /mcp/trips + allowed_hosts: false + registry: + tools: + - 'App\Chatbot\Mcp\Tool\SearchTripsTool' + - 'App\Chatbot\Mcp\TripsOnly\' diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 7f3fd61d..c9e4b43e 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -51,7 +51,7 @@ security: http_basic: realm: 'Mailjet Webhook' mcp: - pattern: ^/mcp$ + pattern: ^/mcp(/trips)?$ stateless: true provider: mcp_provider access_token: @@ -69,7 +69,7 @@ security: # Note: Only the *first* access control that matches will be used access_control: - { path: ^/webhooks/mailjet/newsletter$, roles: ROLE_MAILJET_WEBHOOK, requires_channel: https } - - { path: ^/mcp$, roles: ROLE_CHATBOT_MCP, requires_channel: https } + - { path: ^/mcp(/trips)?$, roles: ROLE_CHATBOT_MCP, requires_channel: https } - { path: ^/authorize, roles: IS_AUTHENTICATED_REMEMBERED, requires_channel: https } - { path: ^/, roles: PUBLIC_ACCESS, requires_channel: https } diff --git a/docs/chatbot-mcp.md b/docs/chatbot-mcp.md index 43e52ddb..27ed76dc 100644 --- a/docs/chatbot-mcp.md +++ b/docs/chatbot-mcp.md @@ -13,7 +13,8 @@ TYPO3 sends no dates or prices, and nothing flows back to TYPO3. ## Endpoint `POST /mcp` (Streamable HTTP; `GET`/`DELETE` are part of the transport), served by `symfony/mcp-bundle` -from `config/packages/mcp.yaml`. Tools only, no resources or prompts. +from `config/packages/mcp.yaml`. Tools only, no resources or prompts. `POST /mcp/trips` is the same +server without the editorial content, see [Trips only](#trips-only-mcptrips). Authentication is a static bearer token per client, `Authorization: Bearer `, checked by `App\Security\McpAccessTokenHandler` on the `mcp` firewall. `APP_MCP_TOKENS` lists the clients as @@ -44,6 +45,23 @@ that prices and availability are a snapshot (`dataAsOf`) and only the booking pa | `search_content` | Questions not about dates or prices | Up to 8 hits with a ~600-character excerpt | | `get_document` | The full text of one `search_content` hit, by `url` | Markdown body, truncated at 20 000 characters | +### Trips only: `/mcp/trips` + +A second server (`chatbot_trips` in `config/packages/mcp.yaml`) for clients that must not answer from +editorial website text, which can contradict the booking system, most visibly on prices. A client picks +the variant by the path it is pointed at; tokens, rate limit and log are those of `/mcp`. + +- Only `search_trips` and `get_trip`. `search_content` and `get_document` do not exist here. +- `get_trip` (`App\Chatbot\Mcp\TripsOnly\GetTripTool`) leaves out the product `description`, which + includes the FAQs, and each hotel's `description`. +- What remains from TYPO3 is structured: trip and hotel names, `url`, `site`, destination, concept and + the hotel `facts` (beds, altitude, distances, sauna, wifi, …; no prices). Which trips exist is still + decided by TYPO3's products and their `searchable` flag, and the free-text `query` of `search_trips` + still matches page text to find a trip, without returning any of it. +- The server instructions tell the model to refer to the trip's `url` for anything the tools don't answer. + +To give a client the editorial content back, point it at `/mcp` again. + Editorial text (product descriptions, FAQs, pages) is maintained by hand in TYPO3 and can lag behind BusPro, e.g. an old price or a changed inclusion. The server instructions and the tool descriptions therefore tell the model that the structured trip fields of `search_trips` and `get_trip` override whatever the text says about dates, prices, pickups @@ -152,4 +170,4 @@ After a complete run that changed something, the codes the trip join cannot plac |---|---| | `APP_CMS_API_BASE_URL` | TYPO3 base URL per stage (also used by `CmsDataProvider`) | | `APP_CMS_API_KEY` | TYPO3 `X-Api-Key`: the content feed and its `contentUrl` pages, and `typo3.client` (`CmsDataProvider`) | -| `APP_MCP_TOKENS` | Bearer tokens for `/mcp`, one per client: `name:token,name:token` | +| `APP_MCP_TOKENS` | Bearer tokens for `/mcp` and `/mcp/trips`, one per client: `name:token,name:token` | diff --git a/src/Chatbot/Mcp/TripsOnly/GetTripTool.php b/src/Chatbot/Mcp/TripsOnly/GetTripTool.php new file mode 100644 index 00000000..0fab1bb7 --- /dev/null +++ b/src/Chatbot/Mcp/TripsOnly/GetTripTool.php @@ -0,0 +1,48 @@ +runner->run('get_trip', get_defined_vars(), function () use ($code, $hotelCode): array { + $trip = $this->catalog->get(trim($code), $hotelCode, editorial: false); + + if (null === $trip) { + throw new ToolCallException(sprintf('No trip or date with code "%s". Use search_trips to find trip codes.', $code)); + } + + return $trip; + }); + } +} diff --git a/src/Chatbot/TripCatalog.php b/src/Chatbot/TripCatalog.php index 3bd9d497..f1b0521e 100644 --- a/src/Chatbot/TripCatalog.php +++ b/src/Chatbot/TripCatalog.php @@ -126,9 +126,12 @@ class TripCatalog * booking documents (SSLMP061226 is product SSLMP on 06.12.26); it narrows the trip to * that date, shown even when it has already started. * + * Without $editorial the product and hotel page texts are left out, and only what is + * structured remains: names, links, hotel facts and everything from the booking system. + * * @return array|null */ - public function get(string $code, ?string $hotelCode = null): ?array + public function get(string $code, ?string $hotelCode = null, bool $editorial = true): ?array { $catalog = $this->load(); $code = (new DateCodeUtility())->sanitize(trim($code)); @@ -175,8 +178,8 @@ class TripCatalog static fn (ContentDocument $other): array => ['site' => $other->getSite(), 'url' => $other->getUrl()], array_filter(array_slice($products, 1), static fn (ContentDocument $other): bool => null !== $other->getUrl()), )), - 'description' => $this->truncate($product->getBodyMd(), self::BODY_LIMIT), - 'hotels' => $this->hotelDetails($catalog, $hotelCodes, $product), + ] + ($editorial ? ['description' => $this->truncate($product->getBodyMd(), self::BODY_LIMIT)] : []) + [ + 'hotels' => $this->hotelDetails($catalog, $hotelCodes, $product, $editorial), 'dates' => $this->dates($catalog, $rows), 'dataAsOf' => $this->dataAsOf($catalog['export']), ]; @@ -495,7 +498,7 @@ class TripCatalog * * @return list> */ - private function hotelDetails(array $catalog, array $hotelCodes, ContentDocument $product): array + private function hotelDetails(array $catalog, array $hotelCodes, ContentDocument $product, bool $editorial): array { $hotels = []; @@ -521,8 +524,7 @@ class TripCatalog 'name' => $hotel->getTitle(), 'url' => $hotel->getUrl(), 'facts' => is_array($hotel->getData()['facts'] ?? null) ? array_filter($hotel->getData()['facts'], static fn (mixed $value): bool => null !== $value && '' !== $value) : [], - 'description' => $this->truncate($hotel->getBodyMd(), self::HOTEL_BODY_LIMIT), - ], $hotels)); + ] + ($editorial ? ['description' => $this->truncate($hotel->getBodyMd(), self::HOTEL_BODY_LIMIT)] : []), $hotels)); } /** diff --git a/tests/Chatbot/TripCatalogTest.php b/tests/Chatbot/TripCatalogTest.php index a30ecdf2..f11c6107 100644 --- a/tests/Chatbot/TripCatalogTest.php +++ b/tests/Chatbot/TripCatalogTest.php @@ -131,6 +131,23 @@ class TripCatalogTest extends TestCase self::assertArrayNotHasKey('guide', $trip['dates'][0]); } + public function testGetWithoutEditorialKeepsTheStructuredFieldsAndDropsThePageTexts(): void + { + $catalog = $this->catalog([$this->row('VTH', 2, '2026-12-05', 'HOT1', [$this->room(450)])]); + $full = $catalog->get('VTH'); + $plain = $catalog->get('VTH', editorial: false); + + self::assertNotNull($full); + self::assertNotNull($plain); + self::assertArrayHasKey('description', $full); + self::assertArrayHasKey('description', $full['hotels'][0]); + self::assertArrayNotHasKey('description', $plain); + self::assertArrayNotHasKey('description', $plain['hotels'][0]); + + unset($full['description'], $full['hotels'][0]['description']); + self::assertSame($full, $plain); + } + public function testPickupSurchargesArePerLegAndAnUnpricedLegIsFree(): void { $row = $this->row('VTH', 2, '2026-12-05', 'HOT1', [$this->room(450)]);