feat: trips-only chatbot MCP endpoint without editorial website text

This commit is contained in:
fromme committed 2026-10-12 14:43:46 +02:00
1 parent 9f805aaec7
commit 26d4140c18
7 files changed
+110 -11

No files matched your search

+1 -1
View File
@@ -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; # 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 # the real value per stage goes in .env.local
APP_CMS_API_KEY= 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 # (e.g. "n8n:…,claude-desktop:…"); empty refuses every request
APP_MCP_TOKENS= APP_MCP_TOKENS=
APP_WEBSITE_BASE_URL="https://www.ep-reisen.de/" APP_WEBSITE_BASE_URL="https://www.ep-reisen.de/"
+14
View File
@@ -14,3 +14,17 @@ mcp:
allowed_hosts: false allowed_hosts: false
registry: registry:
tools: ['App\Chatbot\Mcp\Tool\'] 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\'
+2 -2
View File
@@ -51,7 +51,7 @@ security:
http_basic: http_basic:
realm: 'Mailjet Webhook' realm: 'Mailjet Webhook'
mcp: mcp:
pattern: ^/mcp$ pattern: ^/mcp(/trips)?$
stateless: true stateless: true
provider: mcp_provider provider: mcp_provider
access_token: access_token:
@@ -69,7 +69,7 @@ security:
# Note: Only the *first* access control that matches will be used # Note: Only the *first* access control that matches will be used
access_control: access_control:
- { path: ^/webhooks/mailjet/newsletter$, roles: ROLE_MAILJET_WEBHOOK, requires_channel: https } - { 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: ^/authorize, roles: IS_AUTHENTICATED_REMEMBERED, requires_channel: https }
- { path: ^/, roles: PUBLIC_ACCESS, requires_channel: https } - { path: ^/, roles: PUBLIC_ACCESS, requires_channel: https }
+20 -2
View File
@@ -13,7 +13,8 @@ TYPO3 sends no dates or prices, and nothing flows back to TYPO3.
## Endpoint ## Endpoint
`POST /mcp` (Streamable HTTP; `GET`/`DELETE` are part of the transport), served by `symfony/mcp-bundle` `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 <token>`, checked by Authentication is a static bearer token per client, `Authorization: Bearer <token>`, checked by
`App\Security\McpAccessTokenHandler` on the `mcp` firewall. `APP_MCP_TOKENS` lists the clients as `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 | | `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 | | `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 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 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 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_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_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` |
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Chatbot\Mcp\TripsOnly;
use App\Chatbot\Mcp\ToolCallRunner;
use App\Chatbot\TripCatalog;
use Mcp\Capability\Attribute\McpTool;
use Mcp\Capability\Attribute\Schema;
use Mcp\Exception\ToolCallException;
use Mcp\Schema\Result\CallToolResult;
use Mcp\Schema\ToolAnnotations;
/**
* get_trip of the trips-only server (/mcp/trips): the same trip without the product and
* hotel page texts, whose prices and inclusions can contradict the booking system.
*/
#[McpTool(
name: 'get_trip',
description: 'Full details of one trip: destination, hotels with their facts, all upcoming dates with rooms, prices per person in EUR and availability, pickup points and departure times. Pickup surcharges are per person and per direction: surchargeOutbound for the way there, surchargeReturn for the way back, surchargeRoundTrip for both; quote the round trip unless the user travels one way only. Use after search_trips, or when the user names a specific trip or a booked date (date codes such as SSLMP061226 narrow the answer to that date). Prices and availability are a snapshot as of dataAsOf; the booking page is binding. For anything this does not answer, refer the user to the trip\'s url.',
annotations: new ToolAnnotations(readOnlyHint: true, openWorldHint: false),
)]
final class GetTripTool
{
public function __construct(
private readonly TripCatalog $catalog,
private readonly ToolCallRunner $runner,
) {
}
public function __invoke(
#[Schema(description: 'Trip code as returned by search_trips, or a date code (dateCode) for one date of a trip.', minLength: 1)]
string $code,
#[Schema(description: 'Narrows the dates to one hotel when a trip has several.')]
?string $hotelCode = null,
): CallToolResult {
return $this->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;
});
}
}
+8 -6
View File
@@ -126,9 +126,12 @@ class TripCatalog
* booking documents (SSLMP061226 is product SSLMP on 06.12.26); it narrows the trip to * booking documents (SSLMP061226 is product SSLMP on 06.12.26); it narrows the trip to
* that date, shown even when it has already started. * 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<string, mixed>|null * @return array<string, mixed>|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(); $catalog = $this->load();
$code = (new DateCodeUtility())->sanitize(trim($code)); $code = (new DateCodeUtility())->sanitize(trim($code));
@@ -175,8 +178,8 @@ class TripCatalog
static fn (ContentDocument $other): array => ['site' => $other->getSite(), 'url' => $other->getUrl()], 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()), array_filter(array_slice($products, 1), static fn (ContentDocument $other): bool => null !== $other->getUrl()),
)), )),
'description' => $this->truncate($product->getBodyMd(), self::BODY_LIMIT), ] + ($editorial ? ['description' => $this->truncate($product->getBodyMd(), self::BODY_LIMIT)] : []) + [
'hotels' => $this->hotelDetails($catalog, $hotelCodes, $product), 'hotels' => $this->hotelDetails($catalog, $hotelCodes, $product, $editorial),
'dates' => $this->dates($catalog, $rows), 'dates' => $this->dates($catalog, $rows),
'dataAsOf' => $this->dataAsOf($catalog['export']), 'dataAsOf' => $this->dataAsOf($catalog['export']),
]; ];
@@ -495,7 +498,7 @@ class TripCatalog
* *
* @return list<array<string, mixed>> * @return list<array<string, mixed>>
*/ */
private function hotelDetails(array $catalog, array $hotelCodes, ContentDocument $product): array private function hotelDetails(array $catalog, array $hotelCodes, ContentDocument $product, bool $editorial): array
{ {
$hotels = []; $hotels = [];
@@ -521,8 +524,7 @@ class TripCatalog
'name' => $hotel->getTitle(), 'name' => $hotel->getTitle(),
'url' => $hotel->getUrl(), 'url' => $hotel->getUrl(),
'facts' => is_array($hotel->getData()['facts'] ?? null) ? array_filter($hotel->getData()['facts'], static fn (mixed $value): bool => null !== $value && '' !== $value) : [], '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), ] + ($editorial ? ['description' => $this->truncate($hotel->getBodyMd(), self::HOTEL_BODY_LIMIT)] : []), $hotels));
], $hotels));
} }
/** /**
+17
View File
@@ -131,6 +131,23 @@ class TripCatalogTest extends TestCase
self::assertArrayNotHasKey('guide', $trip['dates'][0]); 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 public function testPickupSurchargesArePerLegAndAnUnpricedLegIsFree(): void
{ {
$row = $this->row('VTH', 2, '2026-12-05', 'HOT1', [$this->room(450)]); $row = $this->row('VTH', 2, '2026-12-05', 'HOT1', [$this->room(450)]);