feat: add MCP server for the website chatbot over a mirrored TYPO3 content feed

This commit is contained in:
fromme committed 2026-10-08 11:46:58 +02:00
1 parent ff7ddf7a9e
commit 1b4384e3f3
52 files changed
+5117 -4

No files matched your search

+56
View File
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Chatbot\Mcp\Tool;
use App\Chatbot\Mcp\ToolCallRunner;
use App\Chatbot\Search\ContentSearch;
use App\Repository\ContentDocumentRepository;
use Mcp\Capability\Attribute\McpTool;
use Mcp\Capability\Attribute\Schema;
use Mcp\Exception\ToolCallException;
use Mcp\Schema\Result\CallToolResult;
use Mcp\Schema\ToolAnnotations;
#[McpTool(
name: 'get_document',
description: 'Read the full text of one search_content hit, by its url.',
annotations: new ToolAnnotations(readOnlyHint: true, openWorldHint: false),
)]
final class GetDocumentTool
{
private const BODY_LIMIT = 20000;
public function __construct(
private readonly ContentDocumentRepository $repository,
private readonly ToolCallRunner $runner,
) {
}
public function __invoke(
#[Schema(description: 'A url returned by search_content.', minLength: 1)]
string $url,
): CallToolResult {
return $this->runner->run('get_document', get_defined_vars(), function () use ($url): array {
$document = $this->repository->findOneIndexedByUrl(trim($url));
if (null === $document) {
throw new ToolCallException('No document at this url. Use a url returned by search_content.');
}
$body = $document->getBodyMd();
$truncated = mb_strlen($body) > self::BODY_LIMIT;
return [
'type' => ContentSearch::publicType($document->getType()),
'title' => $document->getTitle(),
'url' => $document->getUrl(),
'site' => $document->getSite(),
'breadcrumb' => $document->getBreadcrumb(),
'body' => $truncated ? mb_substr($body, 0, self::BODY_LIMIT) : $body,
'truncated' => $truncated,
];
});
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Chatbot\Mcp\Tool;
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;
#[McpTool(
name: 'get_trip',
description: 'Full details of one trip: description, included services, programme, hotel, all upcoming dates with rooms, prices per person in EUR and availability, pickup points and departure times, FAQs. Use after search_trips, or when the user names a specific trip. Prices and availability are a snapshot as of dataAsOf; the booking page is binding.',
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.', 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);
if (null === $trip) {
throw new ToolCallException(sprintf('No trip with code "%s". Use search_trips to find trip codes.', $code));
}
return $trip;
});
}
}
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Chatbot\Mcp\Tool;
use App\Chatbot\Mcp\Sites;
use App\Chatbot\Mcp\ToolCallRunner;
use App\Chatbot\Search\ContentSearch;
use Mcp\Capability\Attribute\McpTool;
use Mcp\Capability\Attribute\Schema;
use Mcp\Exception\ToolCallException;
use Mcp\Schema\Result\CallToolResult;
use Mcp\Schema\ToolAnnotations;
#[McpTool(
name: 'search_content',
description: "Search the websites' editorial content: destinations, hotels, travel information, FAQs, terms, blog articles, general company information. Use for questions that are not about specific dates or prices. Returns excerpts; use get_document for the full text of a hit.",
annotations: new ToolAnnotations(readOnlyHint: true, openWorldHint: false),
)]
final class SearchContentTool
{
public function __construct(
private readonly ContentSearch $search,
private readonly ToolCallRunner $runner,
) {
}
public function __invoke(
#[Schema(description: 'What to look for, in German keywords works best, e.g. "Gepäck Snowboard Bus".', minLength: 1)]
string $query,
#[Schema(description: 'Restrict to one kind of content. destination = region, country or town.', enum: ['page', 'product', 'hotel', 'destination', 'concept', 'news'])]
?string $type = null,
#[Schema(description: Sites::DESCRIPTION, enum: Sites::IDENTIFIERS)]
?string $site = null,
#[Schema(description: 'Number of hits, default 5, at most 8.', minimum: 1, maximum: 8)]
?int $limit = null,
): CallToolResult {
return $this->runner->run('search_content', get_defined_vars(), function () use ($query, $type, $site, $limit): array {
if ('' === trim($query)) {
throw new ToolCallException('query must not be empty.');
}
return ['results' => $this->search->search($query, $type, $site, max(1, min(8, $limit ?? 5)))];
});
}
}
+97
View File
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace App\Chatbot\Mcp\Tool;
use App\Chatbot\Mcp\Sites;
use App\Chatbot\Mcp\ToolCallRunner;
use App\Chatbot\TripCatalog;
use App\Model\Chatbot\TripCriteria;
use Mcp\Capability\Attribute\McpTool;
use Mcp\Capability\Attribute\Schema;
use Mcp\Exception\ToolCallException;
use Mcp\Schema\Result\CallToolResult;
use Mcp\Schema\ToolAnnotations;
#[McpTool(
name: 'search_trips',
description: 'Find bookable trips by structured criteria: travel period, destination, trip type, price, group size. Use for any question about which trips exist, when, where, or how much. Returns trips with their next dates and lowest prices per person in EUR. Prices and availability are a snapshot as of dataAsOf; the booking page is binding.',
annotations: new ToolAnnotations(readOnlyHint: true, openWorldHint: false),
)]
final class SearchTripsTool
{
public function __construct(
private readonly TripCatalog $catalog,
private readonly ToolCallRunner $runner,
) {
}
public function __invoke(
#[Schema(description: 'Free text matched against trip, hotel, destination and concept names and texts, e.g. "Val Thorens" or "Snowboardkurs".')]
?string $query = null,
#[Schema(description: 'Start of the travel period, YYYY-MM-DD. Trips overlapping the period match.', format: 'date')]
?string $dateFrom = null,
#[Schema(description: 'End of the travel period, YYYY-MM-DD.', format: 'date')]
?string $dateTo = null,
#[Schema(description: 'Country name, e.g. "Frankreich".')]
?string $country = null,
#[Schema(description: 'Region, ski area or town name, e.g. "Les 3 Vallées".')]
?string $region = null,
#[Schema(description: 'Trip concept code or name, e.g. ski, party, group.')]
?string $concept = null,
#[Schema(description: 'Maximum price per person in EUR.', minimum: 0)]
?float $maxPrice = null,
#[Schema(description: 'Only dates with at least this many places available.', minimum: 1)]
?int $persons = null,
#[Schema(description: 'true for day trips only, false to exclude them.')]
?bool $daytrip = null,
#[Schema(description: Sites::DESCRIPTION, enum: Sites::IDENTIFIERS)]
?string $site = null,
#[Schema(description: 'Number of trips, default 5, at most 10.', minimum: 1, maximum: 10)]
?int $limit = null,
): CallToolResult {
return $this->runner->run('search_trips', get_defined_vars(), function () use ($query, $dateFrom, $dateTo, $country, $region, $concept, $maxPrice, $persons, $daytrip, $site, $limit): array {
$from = $this->date($dateFrom, 'dateFrom');
$to = $this->date($dateTo, 'dateTo');
if (null !== $from && null !== $to && $from > $to) {
throw new ToolCallException('dateFrom must not be after dateTo.');
}
return $this->catalog->search(new TripCriteria(
query: $this->text($query),
dateFrom: $from,
dateTo: $to,
country: $this->text($country),
region: $this->text($region),
concept: $this->text($concept),
maxPrice: $maxPrice,
persons: $persons,
daytrip: $daytrip,
site: $site,
limit: max(1, min(10, $limit ?? 5)),
));
});
}
private function date(?string $value, string $name): ?\DateTimeImmutable
{
if (null === $value || '' === trim($value)) {
return null;
}
$date = \DateTimeImmutable::createFromFormat('!Y-m-d', trim($value));
if (false === $date || $date->format('Y-m-d') !== trim($value)) {
throw new ToolCallException(sprintf('%s must be a date in the form YYYY-MM-DD.', $name));
}
return $date;
}
private function text(?string $value): ?string
{
return null === $value || '' === trim($value) ? null : trim($value);
}
}