feat: trips-only chatbot MCP endpoint without editorial website text
This commit is contained in:
1 parent
9f805aaec7
commit
26d4140c18
7 files changed
+110
-11
No files matched your search
@@ -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,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\'
|
||||||
@@ -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
@@ -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` |
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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)]);
|
||||||
|
|||||||
Reference in new issue
Block a user