Add SearchMeetupsTool for duplication prevention

- 🔍 Introduced `SearchMeetupsTool` to find existing meetups by name or city before creating new ones.
- ☑️ Updated `CreateMeetupTool` description and logic to enforce pre-checks for existing meetups.
- 🛠️ Adjusted `EinundzwanzigServer` to include `SearchMeetupsTool` in tools list.
-  Added feature tests to verify meetup search functionality and ensure duplication avoidance.
This commit is contained in:
HolgerHatGarKeineNode
2026-06-08 11:10:29 +02:00
parent ab8b91a0af
commit dc2b828777
5 changed files with 104 additions and 2 deletions
+8
View File
@@ -27,6 +27,7 @@ use App\Mcp\Tools\Search\ListCountriesTool;
use App\Mcp\Tools\Search\SearchCitiesTool; use App\Mcp\Tools\Search\SearchCitiesTool;
use App\Mcp\Tools\Search\SearchCoursesTool; use App\Mcp\Tools\Search\SearchCoursesTool;
use App\Mcp\Tools\Search\SearchLecturersTool; use App\Mcp\Tools\Search\SearchLecturersTool;
use App\Mcp\Tools\Search\SearchMeetupsTool;
use App\Mcp\Tools\Search\SearchVenuesTool; use App\Mcp\Tools\Search\SearchVenuesTool;
use App\Mcp\Tools\Venue\CreateVenueTool; use App\Mcp\Tools\Venue\CreateVenueTool;
use App\Mcp\Tools\Venue\ListMyVenuesTool; use App\Mcp\Tools\Venue\ListMyVenuesTool;
@@ -57,6 +58,12 @@ Entitäten immer über ihren NAMEN:
übergeben (Parameter z. B. "city", "country", "lecturer", "course", "venue"); bei Unsicherheit übergeben (Parameter z. B. "city", "country", "lecturer", "course", "venue"); bei Unsicherheit
vorher mit search-cities / search-venues / search-lecturers / search-courses / list-countries vorher mit search-cities / search-venues / search-lecturers / search-courses / list-countries
den genauen Namen ermitteln. den genauen Namen ermitteln.
Bevor ein NEUES Meetup angelegt wird (create-meetup): IMMER zuerst mit search-meetups nach
einem bestehenden Meetup suchen sowohl nach dem Namen als auch nach dem Stadtnamen. Existiert
bereits ein passendes Meetup, KEIN Duplikat anlegen, sondern dem Nutzer das gefundene Meetup
nennen. Nur wenn die Suche kein passendes Meetup liefert, den Nutzer fragen, ob ein neues
Meetup erstellt werden soll und erst nach Bestätigung create-meetup aufrufen.
Termine/Events (Meetup-Termine, Kurs-Events) haben keinen Namen. Hier zuerst list-my-meetup- Termine/Events (Meetup-Termine, Kurs-Events) haben keinen Namen. Hier zuerst list-my-meetup-
events bzw. list-my-course-events aufrufen, dem Nutzer die Einträge zur Auswahl anbieten und events bzw. list-my-course-events aufrufen, dem Nutzer die Einträge zur Auswahl anbieten und
die ID des gewählten Eintrags übergeben ebenfalls ohne den Nutzer nach der ID zu fragen. die ID des gewählten Eintrags übergeben ebenfalls ohne den Nutzer nach der ID zu fragen.
@@ -123,6 +130,7 @@ class EinundzwanzigServer extends Server
UpdateCourseEventTool::class, UpdateCourseEventTool::class,
// Suche / Stammdaten-Lookups // Suche / Stammdaten-Lookups
SearchMeetupsTool::class,
SearchCitiesTool::class, SearchCitiesTool::class,
SearchVenuesTool::class, SearchVenuesTool::class,
SearchLecturersTool::class, SearchLecturersTool::class,
+1 -1
View File
@@ -15,7 +15,7 @@ use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description; use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool; use Laravel\Mcp\Server\Tool;
#[Description('Legt ein neues Meetup für den authentifizierten Nutzer an. Die Stadt wird über ihren Namen angegeben; der Ersteller (created_by) wird automatisch gesetzt.')] #[Description('Legt ein neues Meetup für den authentifizierten Nutzer an. Die Stadt wird über ihren Namen angegeben; der Ersteller (created_by) wird automatisch gesetzt. WICHTIG: Vorher mit search-meetups (Name UND Stadtname) prüfen, ob bereits ein Meetup existiert, und erst nach Rückfrage beim Nutzer anlegen, wenn keines gefunden wurde.')]
class CreateMeetupTool extends Tool class CreateMeetupTool extends Tool
{ {
use ResolvesEntities; use ResolvesEntities;
@@ -0,0 +1,59 @@
<?php
namespace App\Mcp\Tools\Search;
use App\Models\Meetup;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\JsonSchema\Types\Type;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly]
#[Description('Sucht bestehende Meetups (öffentlich) nach Meetup-Name ODER Stadtname und liefert id, name, Stadt und Land, begrenzt auf 10 Einträge. VOR dem Anlegen eines neuen Meetups nutzen, um Duplikate zu vermeiden.')]
class SearchMeetupsTool extends Tool
{
public function handle(Request $request): Response
{
$search = $request->get('search');
$meetups = Meetup::query()
->select('id', 'name', 'city_id', 'slug')
->with(['city:id,name,country_id', 'city.country:id,name'])
->orderBy('name')
->when(
is_string($search) && $search !== '',
function (Builder $query) use ($search): void {
$needle = '%'.mb_strtolower(trim((string) $search)).'%';
$query->where(function (Builder $inner) use ($needle): void {
$inner->whereRaw('LOWER(name) LIKE ?', [$needle])
->orWhereHas('city', fn (Builder $city) => $city->whereRaw('LOWER(cities.name) LIKE ?', [$needle]));
});
}
)
->limit(10)
->get()
->map(fn (Meetup $meetup): array => [
'id' => $meetup->id,
'name' => $meetup->name,
'city' => $meetup->city?->name,
'country' => $meetup->city?->country?->name,
]);
return Response::json($meetups->values());
}
/**
* @return array<string, Type>
*/
public function schema(JsonSchema $schema): array
{
return [
'search' => $schema->string()->description('Suchbegriff: Meetup-Name oder Stadtname (z. B. "München").'),
];
}
}
@@ -17,7 +17,7 @@ it('registers every domain tool on the server', function () {
$property = (new ReflectionClass(EinundzwanzigServer::class))->getProperty('tools'); $property = (new ReflectionClass(EinundzwanzigServer::class))->getProperty('tools');
$tools = $property->getDefaultValue(); $tools = $property->getDefaultValue();
expect($tools)->toHaveCount(30) expect($tools)->toHaveCount(31)
->and($tools)->toContain(CreateMeetupTool::class) ->and($tools)->toContain(CreateMeetupTool::class)
->and($tools)->toContain(UpdateCourseEventTool::class) ->and($tools)->toContain(UpdateCourseEventTool::class)
->and($tools)->toContain(SearchCitiesTool::class); ->and($tools)->toContain(SearchCitiesTool::class);
@@ -0,0 +1,35 @@
<?php
use App\Mcp\Servers\EinundzwanzigServer;
use App\Mcp\Tools\Search\SearchMeetupsTool;
use App\Models\City;
use App\Models\Meetup;
use App\Models\User;
it('finds an existing meetup by its name', function () {
Meetup::factory()->create(['name' => 'Einundzwanzig München']);
EinundzwanzigServer::actingAs(User::factory()->create())
->tool(SearchMeetupsTool::class, ['search' => 'münchen'])
->assertOk()
->assertSee('Einundzwanzig München');
});
it('finds an existing meetup by its city name', function () {
$city = City::factory()->create(['name' => 'Nürnberg']);
Meetup::factory()->create(['name' => 'Bitcoin Treff', 'city_id' => $city->id]);
EinundzwanzigServer::actingAs(User::factory()->create())
->tool(SearchMeetupsTool::class, ['search' => 'Nürnberg'])
->assertOk()
->assertSee('Bitcoin Treff');
});
it('returns no match for an unknown city so a new meetup can be proposed', function () {
Meetup::factory()->create(['name' => 'Einundzwanzig München']);
$response = EinundzwanzigServer::actingAs(User::factory()->create())
->tool(SearchMeetupsTool::class, ['search' => 'Gibtsnichtstadt']);
$response->assertOk()->assertDontSee('Einundzwanzig München');
});