From b6f05bca4164c29952162eabcb89b3ab500041ac Mon Sep 17 00:00:00 2001 From: HolgerHatGarKeineNode <123783602+HolgerHatGarKeineNode@users.noreply.github.com> Date: Mon, 8 Jun 2026 10:35:16 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20`ResolvesEntities`=20concern?= =?UTF-8?q?=20for=20name-based=20ID=20resolution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 🤖 Introduced `ResolvesEntities` trait to simplify entity resolution by name or ID across MCP tools. - 📚 Updated tools (Meetups, Cities, Venues, Courses, Lecturers) to use the concern for resolving related entities (e.g., courses, venues, lecturers). - 🎯 Enhanced tool descriptions and schemas for better name-based parameter handling with fallback support for IDs. - ✅ Added dedicated feature tests for name resolution logic, partial matches, and error handling scenarios. --- app/Mcp/Servers/EinundzwanzigServer.php | 26 +++- app/Mcp/Tools/City/CreateCityTool.php | 13 +- app/Mcp/Tools/City/ShowMyCityTool.php | 14 +- app/Mcp/Tools/City/UpdateCityTool.php | 24 ++- app/Mcp/Tools/Concerns/ResolvesEntities.php | 146 ++++++++++++++++++ app/Mcp/Tools/Course/CreateCourseTool.php | 13 +- app/Mcp/Tools/Course/UpdateCourseTool.php | 24 ++- .../CourseEvent/CreateCourseEventTool.php | 27 +++- .../CourseEvent/UpdateCourseEventTool.php | 21 ++- app/Mcp/Tools/Lecturer/ShowMyLecturerTool.php | 14 +- app/Mcp/Tools/Lecturer/UpdateLecturerTool.php | 16 +- app/Mcp/Tools/Meetup/CreateMeetupTool.php | 13 +- app/Mcp/Tools/Meetup/ShowMyMeetupTool.php | 14 +- app/Mcp/Tools/Meetup/UpdateMeetupTool.php | 24 ++- .../MeetupEvent/CreateMeetupEventTool.php | 19 ++- .../MeetupEvent/ShowMyMeetupEventTool.php | 2 +- .../MeetupEvent/UpdateMeetupEventTool.php | 13 +- app/Mcp/Tools/Venue/CreateVenueTool.php | 13 +- app/Mcp/Tools/Venue/ShowMyVenueTool.php | 14 +- app/Mcp/Tools/Venue/UpdateVenueTool.php | 24 ++- tests/Feature/Mcp/NameResolutionMcpTest.php | 89 +++++++++++ 21 files changed, 485 insertions(+), 78 deletions(-) create mode 100644 app/Mcp/Tools/Concerns/ResolvesEntities.php create mode 100644 tests/Feature/Mcp/NameResolutionMcpTest.php diff --git a/app/Mcp/Servers/EinundzwanzigServer.php b/app/Mcp/Servers/EinundzwanzigServer.php index 778f856..11a7982 100644 --- a/app/Mcp/Servers/EinundzwanzigServer.php +++ b/app/Mcp/Servers/EinundzwanzigServer.php @@ -42,12 +42,28 @@ use Laravel\Mcp\Server\Tool; #[Version('1.0.0')] #[Instructions(<<<'TXT' Dieser Server spiegelt die authentifizierte Einundzwanzig-API. Jeder Aufruf läuft im Kontext -des per Sanctum-Token angemeldeten Nutzers; beim Anlegen wird der Ersteller (created_by) -automatisch auf diesen Nutzer gesetzt. Schreib- und Eigentums-Operationen (update, my-*) sind -nur für den Ersteller oder einen Super-Admin erlaubt. +des angemeldeten Nutzers; beim Anlegen wird der Ersteller (created_by) automatisch gesetzt. +Schreib- und Eigentums-Operationen (update, show-my-*) sind nur für den Ersteller oder einen +Super-Admin erlaubt. -Fremdschlüssel (city_id, venue_id, lecturer_id, course_id) zuerst über die search-* Tools -auflösen, bevor ein Datensatz angelegt oder aktualisiert wird. +WICHTIG – niemals nach numerischen IDs fragen: Nutzer kennen keine internen IDs. Referenziere +Entitäten immer über ihren NAMEN: +- Eigene Datensätze ändern/anzeigen: zuerst das passende list-my-* Tool aufrufen + (list-my-meetups, list-my-cities, list-my-venues, list-my-lecturers, list-my-course-events), + dem Nutzer die Namen als Auswahlliste präsentieren und ihn wählen lassen. Dann das update-/ + show-my-* Tool mit dem gewählten Namen aufrufen (Parameter z. B. "meetup", "city", "venue", + "lecturer", "course"). +- Fremdschlüssel beim Anlegen (Stadt, Land, Referent, Kurs, Veranstaltungsort): den Namen + übergeben (Parameter z. B. "city", "country", "lecturer", "course", "venue"); bei Unsicherheit + vorher mit search-cities / search-venues / search-lecturers / search-courses / list-countries + den genauen Namen ermitteln. +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 +die ID des gewählten Eintrags übergeben – ebenfalls ohne den Nutzer nach der ID zu fragen. + +Die Tools lösen Namen serverseitig auf. Bei Mehrdeutigkeit oder fehlendem Treffer liefern sie +eine Liste der passenden Einträge zurück – diese dem Nutzer zur Auswahl anbieten. Die *_id- +Parameter sind nur ein optionaler Fallback, falls die ID bereits bekannt ist. TXT)] class EinundzwanzigServer extends Server { diff --git a/app/Mcp/Tools/City/CreateCityTool.php b/app/Mcp/Tools/City/CreateCityTool.php index 4daa4c0..a22b661 100644 --- a/app/Mcp/Tools/City/CreateCityTool.php +++ b/app/Mcp/Tools/City/CreateCityTool.php @@ -4,7 +4,9 @@ namespace App\Mcp\Tools\City; use App\Http\Requests\Api\StoreCityRequest; use App\Http\Resources\CityResource; +use App\Mcp\Tools\Concerns\ResolvesEntities; use App\Models\City; +use App\Models\Country; use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\JsonSchema\Types\Type; use Illuminate\Support\Facades\Gate; @@ -13,9 +15,11 @@ use Laravel\Mcp\Response; use Laravel\Mcp\Server\Attributes\Description; use Laravel\Mcp\Server\Tool; -#[Description('Legt eine neue Stadt für den authentifizierten Nutzer an. Der Ersteller (created_by) wird automatisch gesetzt.')] +#[Description('Legt eine neue Stadt für den authentifizierten Nutzer an. Das Land wird über seinen Namen angegeben; der Ersteller (created_by) wird automatisch gesetzt.')] class CreateCityTool extends Tool { + use ResolvesEntities; + public function handle(Request $request): Response { $user = $request->user(); @@ -24,6 +28,10 @@ class CreateCityTool extends Tool return Response::error('Nicht berechtigt, eine Stadt anzulegen.'); } + if ($error = $this->mergeForeignKey($request, 'country', 'country_id', Country::query(), 'Land')) { + return $error; + } + $storeRequest = new StoreCityRequest; $validated = $request->validate( @@ -42,7 +50,8 @@ class CreateCityTool extends Tool public function schema(JsonSchema $schema): array { return [ - 'country_id' => $schema->integer()->description('ID des zugehörigen Landes.')->required(), + 'country' => $schema->string()->description('Name des zugehörigen Landes (z. B. "Deutschland"). Wird automatisch aufgelöst – bei Bedarf per list-countries den genauen Namen ermitteln.'), + 'country_id' => $schema->integer()->description('Optional: ID des Landes, falls bereits bekannt (Alternative zu "country").'), 'name' => $schema->string()->description('Name der Stadt.')->required(), 'longitude' => $schema->number()->description('Längengrad der Stadt.')->required(), 'latitude' => $schema->number()->description('Breitengrad der Stadt.')->required(), diff --git a/app/Mcp/Tools/City/ShowMyCityTool.php b/app/Mcp/Tools/City/ShowMyCityTool.php index abf137b..a83b038 100644 --- a/app/Mcp/Tools/City/ShowMyCityTool.php +++ b/app/Mcp/Tools/City/ShowMyCityTool.php @@ -3,6 +3,7 @@ namespace App\Mcp\Tools\City; use App\Http\Resources\CityResource; +use App\Mcp\Tools\Concerns\ResolvesEntities; use App\Models\City; use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\JsonSchema\Types\Type; @@ -14,15 +15,17 @@ use Laravel\Mcp\Server\Tool; use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; #[IsReadOnly] -#[Description('Zeigt eine einzelne, vom authentifizierten Nutzer erstellte Stadt.')] +#[Description('Zeigt eine deiner Städte (per Name angegeben).')] class ShowMyCityTool extends Tool { + use ResolvesEntities; + public function handle(Request $request): Response { - $city = City::find($request->get('id')); + $city = $this->resolveOwnedByName($request, City::class, 'Städte', 'city'); - if (! $city) { - return Response::error('Stadt nicht gefunden.'); + if ($city instanceof Response) { + return $city; } $user = $request->user(); @@ -40,7 +43,8 @@ class ShowMyCityTool extends Tool public function schema(JsonSchema $schema): array { return [ - 'id' => $schema->integer()->description('ID der Stadt.')->required(), + 'city' => $schema->string()->description('Name der Stadt (aus deinen Städten, siehe list-my-cities).'), + 'id' => $schema->integer()->description('Optional: ID der Stadt, falls bereits bekannt (Alternative zu "city").'), ]; } } diff --git a/app/Mcp/Tools/City/UpdateCityTool.php b/app/Mcp/Tools/City/UpdateCityTool.php index ca5e44e..32e13f7 100644 --- a/app/Mcp/Tools/City/UpdateCityTool.php +++ b/app/Mcp/Tools/City/UpdateCityTool.php @@ -4,7 +4,9 @@ namespace App\Mcp\Tools\City; use App\Http\Requests\Api\UpdateCityRequest; use App\Http\Resources\CityResource; +use App\Mcp\Tools\Concerns\ResolvesEntities; use App\Models\City; +use App\Models\Country; use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\JsonSchema\Types\Type; use Illuminate\Support\Facades\Gate; @@ -13,15 +15,17 @@ use Laravel\Mcp\Response; use Laravel\Mcp\Server\Attributes\Description; use Laravel\Mcp\Server\Tool; -#[Description('Aktualisiert eine bestehende Stadt. Nur der Ersteller oder ein Super-Admin darf sie ändern.')] +#[Description('Aktualisiert eine deiner Städte (per Name angegeben). Nur der Ersteller oder ein Super-Admin darf sie ändern.')] class UpdateCityTool extends Tool { + use ResolvesEntities; + public function handle(Request $request): Response { - $city = City::find($request->get('id')); + $city = $this->resolveOwnedByName($request, City::class, 'Städte', 'city'); - if (! $city) { - return Response::error('Stadt nicht gefunden.'); + if ($city instanceof Response) { + return $city; } $user = $request->user(); @@ -30,6 +34,10 @@ class UpdateCityTool extends Tool return Response::error('Nur der Ersteller oder ein Super-Admin darf diese Stadt ändern.'); } + if ($error = $this->mergeForeignKey($request, 'country', 'country_id', Country::query(), 'Land', false)) { + return $error; + } + $validated = $request->validate((new UpdateCityRequest)->rules()); $city->update($validated); @@ -43,9 +51,11 @@ class UpdateCityTool extends Tool public function schema(JsonSchema $schema): array { return [ - 'id' => $schema->integer()->description('ID der zu aktualisierenden Stadt.')->required(), - 'country_id' => $schema->integer()->description('ID des zugehörigen Landes.'), - 'name' => $schema->string()->description('Name der Stadt.'), + 'city' => $schema->string()->description('Name der zu ändernden Stadt (aus deinen Städten, siehe list-my-cities).'), + 'id' => $schema->integer()->description('Optional: ID der Stadt, falls bereits bekannt (Alternative zu "city").'), + 'country' => $schema->string()->description('Name des zugehörigen Landes (wird automatisch aufgelöst).'), + 'country_id' => $schema->integer()->description('Optional: ID des Landes (Alternative zu "country").'), + 'name' => $schema->string()->description('Neuer Name der Stadt.'), 'longitude' => $schema->number()->description('Längengrad der Stadt.'), 'latitude' => $schema->number()->description('Breitengrad der Stadt.'), 'population' => $schema->integer()->description('Einwohnerzahl der Stadt.'), diff --git a/app/Mcp/Tools/Concerns/ResolvesEntities.php b/app/Mcp/Tools/Concerns/ResolvesEntities.php new file mode 100644 index 0000000..5b3d440 --- /dev/null +++ b/app/Mcp/Tools/Concerns/ResolvesEntities.php @@ -0,0 +1,146 @@ + $modelClass + */ + protected function resolveOwnedByName(Request $request, string $modelClass, string $label, string $nameParam, string $column = 'name'): Model|Response + { + $id = $request->get('id'); + + if ($this->present($id)) { + $found = $modelClass::query()->whereKey($id)->first(); + + if ($found !== null) { + return $found; + } + } + + $owned = $modelClass::query()->where('created_by', $request->user()?->getAuthIdentifier()); + $name = $request->get($nameParam); + + if ($this->present($name)) { + $matches = $this->matchByName($owned, (string) $name, $column); + + if ($matches->count() === 1) { + return $matches->first(); + } + + if ($matches->count() > 1) { + return Response::error("Mehrere {$label} passen zu \"{$name}\": ".$matches->pluck($column)->join('; ').'. Bitte den genauen Namen angeben.'); + } + } + + return $this->optionsError($owned, $label, $column); + } + + /** + * Löst einen Fremdschlüssel über den Namen auf und schreibt die ID in den Request, + * damit die nachgelagerte Validierung sie sieht. Gibt null zurück, wenn nichts zu tun + * ist (ID bereits gesetzt, oder optionaler FK ohne Namen), sonst eine Fehler-Response. + */ + protected function mergeForeignKey(Request $request, string $nameParam, string $idKey, Builder $query, string $label, bool $required = true): ?Response + { + if ($this->present($request->get($idKey))) { + return null; + } + + if (! $this->present($request->get($nameParam))) { + return $required ? Response::error("Bitte einen Namen für {$label} angeben.") : null; + } + + $model = $this->resolveGlobalByName($query, $request->get($nameParam), $label); + + if ($model instanceof Response) { + return $model; + } + + $request->merge([$idKey => $model->id]); + + return null; + } + + /** + * Löst einen global sichtbaren Datensatz (z. B. Stadt, Land, Referent, Kurs, Ort) + * über seinen Namen auf. + */ + protected function resolveGlobalByName(Builder $query, ?string $name, string $label, string $column = 'name'): Model|Response + { + if (! $this->present($name)) { + return Response::error("Bitte einen Namen für {$label} angeben."); + } + + $matches = $this->matchByName($query, (string) $name, $column); + + if ($matches->count() === 1) { + return $matches->first(); + } + + if ($matches->isEmpty()) { + return Response::error("{$label} \"{$name}\" wurde nicht gefunden. Nutze das passende search-Tool, um den genauen Namen zu ermitteln."); + } + + return Response::error("Mehrere {$label} passen zu \"{$name}\": ".$matches->pluck($column)->take(15)->join('; ').'. Bitte präziser angeben.'); + } + + /** + * Case-insensitive Treffer in einer einzigen Abfrage: Teilstring-Suche, exakte + * Treffer nach vorne sortiert (und dadurch nie vom Limit abgeschnitten). Existiert + * mindestens ein exakter Treffer, gewinnt dieser; sonst zählen die Teiltreffer. + * DB-portabel über LOWER(). + * + * @return Collection + */ + private function matchByName(Builder $query, string $name, string $column): Collection + { + $needle = mb_strtolower(trim($name)); + + $matches = (clone $query) + ->whereRaw('LOWER('.$column.') LIKE ?', ['%'.$needle.'%']) + ->orderByRaw('CASE WHEN LOWER('.$column.') = ? THEN 0 ELSE 1 END', [$needle]) + ->limit(25) + ->get(); + + $exact = $matches->filter( + fn (Model $model): bool => mb_strtolower((string) $model->getAttribute($column)) === $needle + )->values(); + + return $exact->isNotEmpty() ? $exact : $matches; + } + + private function optionsError(Builder $owned, string $label, string $column): Response + { + $names = (clone $owned)->orderBy($column)->limit(50)->pluck($column); + + if ($names->isEmpty()) { + return Response::error("Du hast noch keine {$label} angelegt."); + } + + return Response::error("{$label} nicht gefunden. Deine {$label}: ".$names->join('; ').'.'); + } + + private function present(mixed $value): bool + { + return $value !== null && $value !== ''; + } +} diff --git a/app/Mcp/Tools/Course/CreateCourseTool.php b/app/Mcp/Tools/Course/CreateCourseTool.php index 097c33e..def4cd3 100644 --- a/app/Mcp/Tools/Course/CreateCourseTool.php +++ b/app/Mcp/Tools/Course/CreateCourseTool.php @@ -2,7 +2,9 @@ namespace App\Mcp\Tools\Course; +use App\Mcp\Tools\Concerns\ResolvesEntities; use App\Models\Course; +use App\Models\Lecturer; use App\Models\User; use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\JsonSchema\Types\Type; @@ -11,9 +13,11 @@ use Laravel\Mcp\Response; use Laravel\Mcp\Server\Attributes\Description; use Laravel\Mcp\Server\Tool; -#[Description('Legt einen neuen Kurs für den authentifizierten Referenten an. Der Ersteller (created_by) wird automatisch gesetzt.')] +#[Description('Legt einen neuen Kurs für den authentifizierten Referenten an. Der Referent wird über seinen Namen angegeben; der Ersteller (created_by) wird automatisch gesetzt.')] class CreateCourseTool extends Tool { + use ResolvesEntities; + public function handle(Request $request): Response { $user = $request->user(); @@ -22,6 +26,10 @@ class CreateCourseTool extends Tool return Response::error('Nur Referenten (is_lecturer) dürfen Kurse anlegen.'); } + if ($error = $this->mergeForeignKey($request, 'lecturer', 'lecturer_id', Lecturer::query(), 'Referenten')) { + return $error; + } + $validated = $request->validate([ 'name' => ['required', 'string', 'max:255'], 'lecturer_id' => ['required', 'exists:lecturers,id'], @@ -40,7 +48,8 @@ class CreateCourseTool extends Tool { return [ 'name' => $schema->string()->description('Name des Kurses.')->required(), - 'lecturer_id' => $schema->integer()->description('ID des zugehörigen Referenten (vorher per search-lecturers auflösen).')->required(), + 'lecturer' => $schema->string()->description('Name des zugehörigen Referenten. Wird automatisch aufgelöst – bei Bedarf per search-lecturers den genauen Namen ermitteln.'), + 'lecturer_id' => $schema->integer()->description('Optional: ID des Referenten, falls bereits bekannt (Alternative zu "lecturer").'), 'description' => $schema->string()->description('Beschreibung des Kurses.'), ]; } diff --git a/app/Mcp/Tools/Course/UpdateCourseTool.php b/app/Mcp/Tools/Course/UpdateCourseTool.php index 6a651f2..1738a5c 100644 --- a/app/Mcp/Tools/Course/UpdateCourseTool.php +++ b/app/Mcp/Tools/Course/UpdateCourseTool.php @@ -2,7 +2,9 @@ namespace App\Mcp\Tools\Course; +use App\Mcp\Tools\Concerns\ResolvesEntities; use App\Models\Course; +use App\Models\Lecturer; use App\Models\User; use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\JsonSchema\Types\Type; @@ -11,15 +13,17 @@ use Laravel\Mcp\Response; use Laravel\Mcp\Server\Attributes\Description; use Laravel\Mcp\Server\Tool; -#[Description('Aktualisiert einen bestehenden Kurs. Nur der Ersteller oder ein Super-Admin darf ihn ändern.')] +#[Description('Aktualisiert einen deiner Kurse (per Name angegeben). Nur der Ersteller oder ein Super-Admin darf ihn ändern.')] class UpdateCourseTool extends Tool { + use ResolvesEntities; + public function handle(Request $request): Response { - $course = Course::find($request->get('id')); + $course = $this->resolveOwnedByName($request, Course::class, 'Kurse', 'course'); - if (! $course) { - return Response::error('Kurs nicht gefunden.'); + if ($course instanceof Response) { + return $course; } $user = $request->user(); @@ -28,6 +32,10 @@ class UpdateCourseTool extends Tool return Response::error('Nur der Ersteller des Kurses oder ein Super-Admin darf ihn ändern.'); } + if ($error = $this->mergeForeignKey($request, 'lecturer', 'lecturer_id', Lecturer::query(), 'Referenten', false)) { + return $error; + } + $validated = $request->validate([ 'name' => ['sometimes', 'required', 'string', 'max:255'], 'lecturer_id' => ['sometimes', 'required', 'exists:lecturers,id'], @@ -45,9 +53,11 @@ class UpdateCourseTool extends Tool public function schema(JsonSchema $schema): array { return [ - 'id' => $schema->integer()->description('ID des zu aktualisierenden Kurses.')->required(), - 'name' => $schema->string()->description('Name des Kurses.'), - 'lecturer_id' => $schema->integer()->description('ID des zugehörigen Referenten.'), + 'course' => $schema->string()->description('Name des zu ändernden Kurses (aus deinen Kursen, siehe list-my-course-events bzw. search-courses).'), + 'id' => $schema->integer()->description('Optional: ID des Kurses, falls bereits bekannt (Alternative zu "course").'), + 'name' => $schema->string()->description('Neuer Name des Kurses.'), + 'lecturer' => $schema->string()->description('Name des zugehörigen Referenten (wird automatisch aufgelöst).'), + 'lecturer_id' => $schema->integer()->description('Optional: ID des Referenten (Alternative zu "lecturer").'), 'description' => $schema->string()->description('Beschreibung des Kurses.'), ]; } diff --git a/app/Mcp/Tools/CourseEvent/CreateCourseEventTool.php b/app/Mcp/Tools/CourseEvent/CreateCourseEventTool.php index e616883..0564160 100644 --- a/app/Mcp/Tools/CourseEvent/CreateCourseEventTool.php +++ b/app/Mcp/Tools/CourseEvent/CreateCourseEventTool.php @@ -2,8 +2,11 @@ namespace App\Mcp\Tools\CourseEvent; +use App\Mcp\Tools\Concerns\ResolvesEntities; +use App\Models\Course; use App\Models\CourseEvent; use App\Models\User; +use App\Models\Venue; use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\JsonSchema\Types\Type; use Laravel\Mcp\Request; @@ -11,9 +14,11 @@ use Laravel\Mcp\Response; use Laravel\Mcp\Server\Attributes\Description; use Laravel\Mcp\Server\Tool; -#[Description('Legt ein neues Kurs-Event für den authentifizierten Referenten an. Der Ersteller (created_by) wird automatisch gesetzt.')] +#[Description('Legt ein neues Kurs-Event für den authentifizierten Referenten an. Kurs und Veranstaltungsort werden über ihre Namen angegeben; der Ersteller (created_by) wird automatisch gesetzt.')] class CreateCourseEventTool extends Tool { + use ResolvesEntities; + public function handle(Request $request): Response { $user = $request->user(); @@ -22,6 +27,20 @@ class CreateCourseEventTool extends Tool return Response::error('Nur Referenten (is_lecturer) dürfen Kurs-Events anlegen.'); } + if (! $this->present($request->get('course_id'))) { + $course = $this->resolveOwnedByName($request, Course::class, 'Kurse', 'course'); + + if ($course instanceof Response) { + return $course; + } + + $request->merge(['course_id' => $course->id]); + } + + if ($error = $this->mergeForeignKey($request, 'venue', 'venue_id', Venue::query(), 'Veranstaltungsorte')) { + return $error; + } + $validated = $request->validate([ 'course_id' => ['required', 'integer', 'exists:courses,id'], 'venue_id' => ['required', 'integer', 'exists:venues,id'], @@ -41,8 +60,10 @@ class CreateCourseEventTool extends Tool public function schema(JsonSchema $schema): array { return [ - 'course_id' => $schema->integer()->description('ID des zugehörigen Kurses (vorher per search-courses auflösen).')->required(), - 'venue_id' => $schema->integer()->description('ID des Veranstaltungsorts (vorher per search-venues auflösen).')->required(), + 'course' => $schema->string()->description('Name deines Kurses, zu dem das Event gehört. Wird automatisch aufgelöst – sonst zuerst search-courses aufrufen.'), + 'course_id' => $schema->integer()->description('Optional: ID des Kurses, falls bereits bekannt (Alternative zu "course").'), + 'venue' => $schema->string()->description('Name des Veranstaltungsorts. Wird automatisch aufgelöst – bei Bedarf per search-venues den genauen Namen ermitteln.'), + 'venue_id' => $schema->integer()->description('Optional: ID des Veranstaltungsorts, falls bereits bekannt (Alternative zu "venue").'), 'from' => $schema->string()->description('Startzeitpunkt (Datum/Uhrzeit).')->required(), 'to' => $schema->string()->description('Endzeitpunkt (Datum/Uhrzeit), gleich oder nach dem Start.')->required(), 'link' => $schema->string()->description('URL mit weiteren Informationen zum Kurs-Event.')->required(), diff --git a/app/Mcp/Tools/CourseEvent/UpdateCourseEventTool.php b/app/Mcp/Tools/CourseEvent/UpdateCourseEventTool.php index 6caed8c..0829324 100644 --- a/app/Mcp/Tools/CourseEvent/UpdateCourseEventTool.php +++ b/app/Mcp/Tools/CourseEvent/UpdateCourseEventTool.php @@ -2,8 +2,11 @@ namespace App\Mcp\Tools\CourseEvent; +use App\Mcp\Tools\Concerns\ResolvesEntities; +use App\Models\Course; use App\Models\CourseEvent; use App\Models\User; +use App\Models\Venue; use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\JsonSchema\Types\Type; use Laravel\Mcp\Request; @@ -14,6 +17,8 @@ use Laravel\Mcp\Server\Tool; #[Description('Aktualisiert ein bestehendes Kurs-Event. Nur der Ersteller oder ein Super-Admin darf es ändern.')] class UpdateCourseEventTool extends Tool { + use ResolvesEntities; + public function handle(Request $request): Response { $courseEvent = CourseEvent::find($request->get('id')); @@ -28,6 +33,14 @@ class UpdateCourseEventTool extends Tool return Response::error('Nur der Ersteller des Kurs-Events oder ein Super-Admin darf es ändern.'); } + if ($error = $this->mergeForeignKey($request, 'course', 'course_id', Course::query()->where('created_by', $user->getAuthIdentifier()), 'Kurse', false)) { + return $error; + } + + if ($error = $this->mergeForeignKey($request, 'venue', 'venue_id', Venue::query(), 'Veranstaltungsorte', false)) { + return $error; + } + $validated = $request->validate([ 'course_id' => ['sometimes', 'required', 'integer', 'exists:courses,id'], 'venue_id' => ['sometimes', 'required', 'integer', 'exists:venues,id'], @@ -47,9 +60,11 @@ class UpdateCourseEventTool extends Tool public function schema(JsonSchema $schema): array { return [ - 'id' => $schema->integer()->description('ID des zu aktualisierenden Kurs-Events.')->required(), - 'course_id' => $schema->integer()->description('ID des zugehörigen Kurses.'), - 'venue_id' => $schema->integer()->description('ID des Veranstaltungsorts.'), + 'id' => $schema->integer()->description('ID des zu aktualisierenden Kurs-Events (über list-my-course-events ermitteln; nicht den Nutzer danach fragen).')->required(), + 'course' => $schema->string()->description('Name des zugehörigen Kurses, falls geändert werden soll (wird automatisch aufgelöst).'), + 'course_id' => $schema->integer()->description('Optional: ID des Kurses (Alternative zu "course").'), + 'venue' => $schema->string()->description('Name des Veranstaltungsorts, falls geändert werden soll (wird automatisch aufgelöst).'), + 'venue_id' => $schema->integer()->description('Optional: ID des Veranstaltungsorts (Alternative zu "venue").'), 'from' => $schema->string()->description('Startzeitpunkt (Datum/Uhrzeit).'), 'to' => $schema->string()->description('Endzeitpunkt (Datum/Uhrzeit), gleich oder nach dem Start.'), 'link' => $schema->string()->description('URL mit weiteren Informationen zum Kurs-Event.'), diff --git a/app/Mcp/Tools/Lecturer/ShowMyLecturerTool.php b/app/Mcp/Tools/Lecturer/ShowMyLecturerTool.php index a0c76a1..2a6bdbd 100644 --- a/app/Mcp/Tools/Lecturer/ShowMyLecturerTool.php +++ b/app/Mcp/Tools/Lecturer/ShowMyLecturerTool.php @@ -3,6 +3,7 @@ namespace App\Mcp\Tools\Lecturer; use App\Http\Resources\LecturerResource; +use App\Mcp\Tools\Concerns\ResolvesEntities; use App\Models\Lecturer; use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\JsonSchema\Types\Type; @@ -14,15 +15,17 @@ use Laravel\Mcp\Server\Tool; use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; #[IsReadOnly] -#[Description('Zeigt einen einzelnen, vom authentifizierten Nutzer erstellten Referenten.')] +#[Description('Zeigt einen deiner Referenten (per Name angegeben).')] class ShowMyLecturerTool extends Tool { + use ResolvesEntities; + public function handle(Request $request): Response { - $lecturer = Lecturer::find($request->get('id')); + $lecturer = $this->resolveOwnedByName($request, Lecturer::class, 'Referenten', 'lecturer'); - if (! $lecturer) { - return Response::error('Referent nicht gefunden.'); + if ($lecturer instanceof Response) { + return $lecturer; } $user = $request->user(); @@ -40,7 +43,8 @@ class ShowMyLecturerTool extends Tool public function schema(JsonSchema $schema): array { return [ - 'id' => $schema->integer()->description('ID des Referenten.')->required(), + 'lecturer' => $schema->string()->description('Name des Referenten (aus deinen Referenten, siehe list-my-lecturers).'), + 'id' => $schema->integer()->description('Optional: ID des Referenten, falls bereits bekannt (Alternative zu "lecturer").'), ]; } } diff --git a/app/Mcp/Tools/Lecturer/UpdateLecturerTool.php b/app/Mcp/Tools/Lecturer/UpdateLecturerTool.php index fe25d43..5bfd10c 100644 --- a/app/Mcp/Tools/Lecturer/UpdateLecturerTool.php +++ b/app/Mcp/Tools/Lecturer/UpdateLecturerTool.php @@ -4,6 +4,7 @@ namespace App\Mcp\Tools\Lecturer; use App\Http\Requests\Api\UpdateLecturerRequest; use App\Http\Resources\LecturerResource; +use App\Mcp\Tools\Concerns\ResolvesEntities; use App\Models\Lecturer; use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\JsonSchema\Types\Type; @@ -13,15 +14,17 @@ use Laravel\Mcp\Response; use Laravel\Mcp\Server\Attributes\Description; use Laravel\Mcp\Server\Tool; -#[Description('Aktualisiert einen bestehenden Referenten. Nur der Ersteller oder ein Super-Admin darf ihn ändern.')] +#[Description('Aktualisiert einen deiner Referenten (per Name angegeben). Nur der Ersteller oder ein Super-Admin darf ihn ändern.')] class UpdateLecturerTool extends Tool { + use ResolvesEntities; + public function handle(Request $request): Response { - $lecturer = Lecturer::find($request->get('id')); + $lecturer = $this->resolveOwnedByName($request, Lecturer::class, 'Referenten', 'lecturer'); - if (! $lecturer) { - return Response::error('Referent nicht gefunden.'); + if ($lecturer instanceof Response) { + return $lecturer; } $user = $request->user(); @@ -43,8 +46,9 @@ class UpdateLecturerTool extends Tool public function schema(JsonSchema $schema): array { return [ - 'id' => $schema->integer()->description('ID des zu aktualisierenden Referenten.')->required(), - 'name' => $schema->string()->description('Name des Referenten.'), + 'lecturer' => $schema->string()->description('Name des zu ändernden Referenten (aus deinen Referenten, siehe list-my-lecturers).'), + 'id' => $schema->integer()->description('Optional: ID des Referenten, falls bereits bekannt (Alternative zu "lecturer").'), + 'name' => $schema->string()->description('Neuer Name des Referenten.'), 'subtitle' => $schema->string()->description('Untertitel.'), 'intro' => $schema->string()->description('Einleitungstext.'), 'description' => $schema->string()->description('Beschreibung.'), diff --git a/app/Mcp/Tools/Meetup/CreateMeetupTool.php b/app/Mcp/Tools/Meetup/CreateMeetupTool.php index e00fc38..4994eeb 100644 --- a/app/Mcp/Tools/Meetup/CreateMeetupTool.php +++ b/app/Mcp/Tools/Meetup/CreateMeetupTool.php @@ -4,6 +4,8 @@ namespace App\Mcp\Tools\Meetup; use App\Http\Requests\Api\StoreMeetupRequest; use App\Http\Resources\MeetupResource; +use App\Mcp\Tools\Concerns\ResolvesEntities; +use App\Models\City; use App\Models\Meetup; use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\JsonSchema\Types\Type; @@ -13,9 +15,11 @@ use Laravel\Mcp\Response; use Laravel\Mcp\Server\Attributes\Description; use Laravel\Mcp\Server\Tool; -#[Description('Legt ein neues Meetup für den authentifizierten Nutzer an. 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.')] class CreateMeetupTool extends Tool { + use ResolvesEntities; + public function handle(Request $request): Response { $user = $request->user(); @@ -24,6 +28,10 @@ class CreateMeetupTool extends Tool return Response::error('Nicht berechtigt, ein Meetup anzulegen.'); } + if ($error = $this->mergeForeignKey($request, 'city', 'city_id', City::query(), 'Stadt')) { + return $error; + } + $storeRequest = new StoreMeetupRequest; $validated = $request->validate( @@ -43,7 +51,8 @@ class CreateMeetupTool extends Tool { return [ 'name' => $schema->string()->description('Name des Meetups.')->required(), - 'city_id' => $schema->integer()->description('ID der zugehörigen Stadt (vorher per search-cities auflösen).')->required(), + 'city' => $schema->string()->description('Name der zugehörigen Stadt (z. B. "Ansbach"). Wird automatisch aufgelöst – bei Bedarf per search-cities den genauen Namen ermitteln.'), + 'city_id' => $schema->integer()->description('Optional: ID der Stadt, falls bereits bekannt (Alternative zu "city").'), 'intro' => $schema->string()->description('Einleitungstext.'), 'telegram_link' => $schema->string()->description('Telegram-Gruppen-URL.'), 'webpage' => $schema->string()->description('Webseiten-URL.'), diff --git a/app/Mcp/Tools/Meetup/ShowMyMeetupTool.php b/app/Mcp/Tools/Meetup/ShowMyMeetupTool.php index f7fd6f7..c0a2d4f 100644 --- a/app/Mcp/Tools/Meetup/ShowMyMeetupTool.php +++ b/app/Mcp/Tools/Meetup/ShowMyMeetupTool.php @@ -3,6 +3,7 @@ namespace App\Mcp\Tools\Meetup; use App\Http\Resources\MeetupResource; +use App\Mcp\Tools\Concerns\ResolvesEntities; use App\Models\Meetup; use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\JsonSchema\Types\Type; @@ -14,15 +15,17 @@ use Laravel\Mcp\Server\Tool; use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; #[IsReadOnly] -#[Description('Zeigt ein einzelnes, vom authentifizierten Nutzer erstelltes Meetup.')] +#[Description('Zeigt eines deiner Meetups (per Name angegeben).')] class ShowMyMeetupTool extends Tool { + use ResolvesEntities; + public function handle(Request $request): Response { - $meetup = Meetup::find($request->get('id')); + $meetup = $this->resolveOwnedByName($request, Meetup::class, 'Meetups', 'meetup'); - if (! $meetup) { - return Response::error('Meetup nicht gefunden.'); + if ($meetup instanceof Response) { + return $meetup; } $user = $request->user(); @@ -40,7 +43,8 @@ class ShowMyMeetupTool extends Tool public function schema(JsonSchema $schema): array { return [ - 'id' => $schema->integer()->description('ID des Meetups.')->required(), + 'meetup' => $schema->string()->description('Name des Meetups (aus deinen Meetups, siehe list-my-meetups).'), + 'id' => $schema->integer()->description('Optional: ID des Meetups, falls bereits bekannt (Alternative zu "meetup").'), ]; } } diff --git a/app/Mcp/Tools/Meetup/UpdateMeetupTool.php b/app/Mcp/Tools/Meetup/UpdateMeetupTool.php index d2bb869..035651a 100644 --- a/app/Mcp/Tools/Meetup/UpdateMeetupTool.php +++ b/app/Mcp/Tools/Meetup/UpdateMeetupTool.php @@ -4,6 +4,8 @@ namespace App\Mcp\Tools\Meetup; use App\Http\Requests\Api\UpdateMeetupRequest; use App\Http\Resources\MeetupResource; +use App\Mcp\Tools\Concerns\ResolvesEntities; +use App\Models\City; use App\Models\Meetup; use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\JsonSchema\Types\Type; @@ -13,15 +15,17 @@ use Laravel\Mcp\Response; use Laravel\Mcp\Server\Attributes\Description; use Laravel\Mcp\Server\Tool; -#[Description('Aktualisiert ein bestehendes Meetup. Nur der Ersteller oder ein Super-Admin darf es ändern.')] +#[Description('Aktualisiert eines deiner Meetups (per Name angegeben). Nur der Ersteller oder ein Super-Admin darf es ändern.')] class UpdateMeetupTool extends Tool { + use ResolvesEntities; + public function handle(Request $request): Response { - $meetup = Meetup::find($request->get('id')); + $meetup = $this->resolveOwnedByName($request, Meetup::class, 'Meetups', 'meetup'); - if (! $meetup) { - return Response::error('Meetup nicht gefunden.'); + if ($meetup instanceof Response) { + return $meetup; } $user = $request->user(); @@ -30,6 +34,10 @@ class UpdateMeetupTool extends Tool return Response::error('Nur der Ersteller oder ein Super-Admin darf dieses Meetup ändern.'); } + if ($error = $this->mergeForeignKey($request, 'city', 'city_id', City::query(), 'Stadt', false)) { + return $error; + } + $validated = $request->validate((new UpdateMeetupRequest)->rules()); $meetup->update($validated); @@ -43,9 +51,11 @@ class UpdateMeetupTool extends Tool public function schema(JsonSchema $schema): array { return [ - 'id' => $schema->integer()->description('ID des zu aktualisierenden Meetups.')->required(), - 'name' => $schema->string()->description('Name des Meetups.'), - 'city_id' => $schema->integer()->description('ID der zugehörigen Stadt.'), + 'meetup' => $schema->string()->description('Name des zu ändernden Meetups (aus deinen Meetups, siehe list-my-meetups).'), + 'id' => $schema->integer()->description('Optional: ID des Meetups, falls bereits bekannt (Alternative zu "meetup").'), + 'name' => $schema->string()->description('Neuer Name des Meetups.'), + 'city' => $schema->string()->description('Name der zugehörigen Stadt (wird automatisch aufgelöst).'), + 'city_id' => $schema->integer()->description('Optional: ID der Stadt (Alternative zu "city").'), 'intro' => $schema->string()->description('Einleitungstext.'), 'telegram_link' => $schema->string()->description('Telegram-Gruppen-URL.'), 'webpage' => $schema->string()->description('Webseiten-URL.'), diff --git a/app/Mcp/Tools/MeetupEvent/CreateMeetupEventTool.php b/app/Mcp/Tools/MeetupEvent/CreateMeetupEventTool.php index 1341593..5cd2e9d 100644 --- a/app/Mcp/Tools/MeetupEvent/CreateMeetupEventTool.php +++ b/app/Mcp/Tools/MeetupEvent/CreateMeetupEventTool.php @@ -4,6 +4,8 @@ namespace App\Mcp\Tools\MeetupEvent; use App\Http\Requests\Api\StoreMeetupEventRequest; use App\Http\Resources\MeetupEventResource; +use App\Mcp\Tools\Concerns\ResolvesEntities; +use App\Models\Meetup; use App\Models\MeetupEvent; use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\JsonSchema\Types\Type; @@ -13,9 +15,11 @@ use Laravel\Mcp\Response; use Laravel\Mcp\Server\Attributes\Description; use Laravel\Mcp\Server\Tool; -#[Description('Legt einen neuen Meetup-Termin für den authentifizierten Nutzer an. Der Ersteller (created_by) wird automatisch gesetzt.')] +#[Description('Legt einen neuen Meetup-Termin für eines der eigenen Meetups an. Das Meetup wird über seinen Namen angegeben; der Ersteller (created_by) wird automatisch gesetzt.')] class CreateMeetupEventTool extends Tool { + use ResolvesEntities; + public function handle(Request $request): Response { $user = $request->user(); @@ -24,6 +28,16 @@ class CreateMeetupEventTool extends Tool return Response::error('Nicht berechtigt, einen Meetup-Termin anzulegen.'); } + if (! $this->present($request->get('meetup_id'))) { + $meetup = $this->resolveOwnedByName($request, Meetup::class, 'Meetups', 'meetup'); + + if ($meetup instanceof Response) { + return $meetup; + } + + $request->merge(['meetup_id' => $meetup->id]); + } + $storeRequest = new StoreMeetupEventRequest; $validated = $request->validate( @@ -42,7 +56,8 @@ class CreateMeetupEventTool extends Tool public function schema(JsonSchema $schema): array { return [ - 'meetup_id' => $schema->integer()->description('ID des zugehörigen Meetups (vorher per search-meetups auflösen).')->required(), + 'meetup' => $schema->string()->description('Name deines Meetups, zu dem der Termin gehört (z. B. "Einundzwanzig Ansbach"). Wird automatisch aufgelöst – sonst zuerst list-my-meetups aufrufen und den Nutzer auswählen lassen.'), + 'meetup_id' => $schema->integer()->description('Optional: ID des Meetups, falls bereits bekannt (Alternative zu "meetup").'), 'start' => $schema->string()->description('Startzeitpunkt als Datum/Uhrzeit (z. B. 2026-08-01 18:00:00).')->required(), 'location' => $schema->string()->description('Veranstaltungsort.'), 'description' => $schema->string()->description('Beschreibung des Termins.'), diff --git a/app/Mcp/Tools/MeetupEvent/ShowMyMeetupEventTool.php b/app/Mcp/Tools/MeetupEvent/ShowMyMeetupEventTool.php index 1d8ec00..7a22312 100644 --- a/app/Mcp/Tools/MeetupEvent/ShowMyMeetupEventTool.php +++ b/app/Mcp/Tools/MeetupEvent/ShowMyMeetupEventTool.php @@ -40,7 +40,7 @@ class ShowMyMeetupEventTool extends Tool public function schema(JsonSchema $schema): array { return [ - 'id' => $schema->integer()->description('ID des Meetup-Termins.')->required(), + 'id' => $schema->integer()->description('ID des Meetup-Termins (über list-my-meetup-events ermitteln; nicht den Nutzer danach fragen).')->required(), ]; } } diff --git a/app/Mcp/Tools/MeetupEvent/UpdateMeetupEventTool.php b/app/Mcp/Tools/MeetupEvent/UpdateMeetupEventTool.php index 8d6e669..c0fe717 100644 --- a/app/Mcp/Tools/MeetupEvent/UpdateMeetupEventTool.php +++ b/app/Mcp/Tools/MeetupEvent/UpdateMeetupEventTool.php @@ -4,6 +4,8 @@ namespace App\Mcp\Tools\MeetupEvent; use App\Http\Requests\Api\UpdateMeetupEventRequest; use App\Http\Resources\MeetupEventResource; +use App\Mcp\Tools\Concerns\ResolvesEntities; +use App\Models\Meetup; use App\Models\MeetupEvent; use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\JsonSchema\Types\Type; @@ -16,6 +18,8 @@ use Laravel\Mcp\Server\Tool; #[Description('Aktualisiert einen bestehenden Meetup-Termin. Nur der Ersteller oder ein Super-Admin darf ihn ändern.')] class UpdateMeetupEventTool extends Tool { + use ResolvesEntities; + public function handle(Request $request): Response { $meetupEvent = MeetupEvent::find($request->get('id')); @@ -30,6 +34,10 @@ class UpdateMeetupEventTool extends Tool return Response::error('Nur der Ersteller oder ein Super-Admin darf diesen Meetup-Termin ändern.'); } + if ($error = $this->mergeForeignKey($request, 'meetup', 'meetup_id', Meetup::query()->where('created_by', $user->getAuthIdentifier()), 'Meetups', false)) { + return $error; + } + $validated = $request->validate((new UpdateMeetupEventRequest)->rules()); $meetupEvent->update($validated); @@ -43,8 +51,9 @@ class UpdateMeetupEventTool extends Tool public function schema(JsonSchema $schema): array { return [ - 'id' => $schema->integer()->description('ID des zu aktualisierenden Meetup-Termins.')->required(), - 'meetup_id' => $schema->integer()->description('ID des zugehörigen Meetups.'), + 'id' => $schema->integer()->description('ID des zu aktualisierenden Meetup-Termins (über list-my-meetup-events ermitteln; nicht den Nutzer danach fragen).')->required(), + 'meetup' => $schema->string()->description('Name des zugehörigen Meetups, falls geändert werden soll (wird automatisch aufgelöst).'), + 'meetup_id' => $schema->integer()->description('Optional: ID des Meetups (Alternative zu "meetup").'), 'start' => $schema->string()->description('Startzeitpunkt als Datum/Uhrzeit (z. B. 2026-08-01 18:00:00).'), 'location' => $schema->string()->description('Veranstaltungsort.'), 'description' => $schema->string()->description('Beschreibung des Termins.'), diff --git a/app/Mcp/Tools/Venue/CreateVenueTool.php b/app/Mcp/Tools/Venue/CreateVenueTool.php index d988475..5cdbf3a 100644 --- a/app/Mcp/Tools/Venue/CreateVenueTool.php +++ b/app/Mcp/Tools/Venue/CreateVenueTool.php @@ -4,6 +4,8 @@ namespace App\Mcp\Tools\Venue; use App\Http\Requests\Api\StoreVenueRequest; use App\Http\Resources\VenueResource; +use App\Mcp\Tools\Concerns\ResolvesEntities; +use App\Models\City; use App\Models\Venue; use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\JsonSchema\Types\Type; @@ -13,9 +15,11 @@ use Laravel\Mcp\Response; use Laravel\Mcp\Server\Attributes\Description; use Laravel\Mcp\Server\Tool; -#[Description('Legt einen neuen Veranstaltungsort (Venue) für den authentifizierten Nutzer an. Der Ersteller (created_by) wird automatisch gesetzt.')] +#[Description('Legt einen neuen Veranstaltungsort (Venue) für den authentifizierten Nutzer an. Die Stadt wird über ihren Namen angegeben; der Ersteller (created_by) wird automatisch gesetzt.')] class CreateVenueTool extends Tool { + use ResolvesEntities; + public function handle(Request $request): Response { $user = $request->user(); @@ -24,6 +28,10 @@ class CreateVenueTool extends Tool return Response::error('Nicht berechtigt, einen Veranstaltungsort anzulegen.'); } + if ($error = $this->mergeForeignKey($request, 'city', 'city_id', City::query(), 'Stadt')) { + return $error; + } + $storeRequest = new StoreVenueRequest; $validated = $request->validate( @@ -42,7 +50,8 @@ class CreateVenueTool extends Tool public function schema(JsonSchema $schema): array { return [ - 'city_id' => $schema->integer()->description('ID der zugehörigen Stadt (vorher per search-cities auflösen).')->required(), + 'city' => $schema->string()->description('Name der zugehörigen Stadt (z. B. "Ansbach"). Wird automatisch aufgelöst – bei Bedarf per search-cities den genauen Namen ermitteln.'), + 'city_id' => $schema->integer()->description('Optional: ID der Stadt, falls bereits bekannt (Alternative zu "city").'), 'name' => $schema->string()->description('Name des Veranstaltungsorts.')->required(), 'street' => $schema->string()->description('Straße und Hausnummer des Veranstaltungsorts.')->required(), ]; diff --git a/app/Mcp/Tools/Venue/ShowMyVenueTool.php b/app/Mcp/Tools/Venue/ShowMyVenueTool.php index 24dc795..42c11df 100644 --- a/app/Mcp/Tools/Venue/ShowMyVenueTool.php +++ b/app/Mcp/Tools/Venue/ShowMyVenueTool.php @@ -3,6 +3,7 @@ namespace App\Mcp\Tools\Venue; use App\Http\Resources\VenueResource; +use App\Mcp\Tools\Concerns\ResolvesEntities; use App\Models\Venue; use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\JsonSchema\Types\Type; @@ -14,15 +15,17 @@ use Laravel\Mcp\Server\Tool; use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; #[IsReadOnly] -#[Description('Zeigt einen einzelnen, vom authentifizierten Nutzer erstellten Veranstaltungsort.')] +#[Description('Zeigt einen deiner Veranstaltungsorte (per Name angegeben).')] class ShowMyVenueTool extends Tool { + use ResolvesEntities; + public function handle(Request $request): Response { - $venue = Venue::find($request->get('id')); + $venue = $this->resolveOwnedByName($request, Venue::class, 'Veranstaltungsorte', 'venue'); - if (! $venue) { - return Response::error('Veranstaltungsort nicht gefunden.'); + if ($venue instanceof Response) { + return $venue; } $user = $request->user(); @@ -40,7 +43,8 @@ class ShowMyVenueTool extends Tool public function schema(JsonSchema $schema): array { return [ - 'id' => $schema->integer()->description('ID des Veranstaltungsorts.')->required(), + 'venue' => $schema->string()->description('Name des Veranstaltungsorts (aus deinen Orten, siehe list-my-venues).'), + 'id' => $schema->integer()->description('Optional: ID des Veranstaltungsorts, falls bereits bekannt (Alternative zu "venue").'), ]; } } diff --git a/app/Mcp/Tools/Venue/UpdateVenueTool.php b/app/Mcp/Tools/Venue/UpdateVenueTool.php index f2a9d4f..83f8fa5 100644 --- a/app/Mcp/Tools/Venue/UpdateVenueTool.php +++ b/app/Mcp/Tools/Venue/UpdateVenueTool.php @@ -4,6 +4,8 @@ namespace App\Mcp\Tools\Venue; use App\Http\Requests\Api\UpdateVenueRequest; use App\Http\Resources\VenueResource; +use App\Mcp\Tools\Concerns\ResolvesEntities; +use App\Models\City; use App\Models\Venue; use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\JsonSchema\Types\Type; @@ -13,15 +15,17 @@ use Laravel\Mcp\Response; use Laravel\Mcp\Server\Attributes\Description; use Laravel\Mcp\Server\Tool; -#[Description('Aktualisiert einen bestehenden Veranstaltungsort (Venue). Nur der Ersteller oder ein Super-Admin darf ihn ändern.')] +#[Description('Aktualisiert einen deiner Veranstaltungsorte (per Name angegeben). Nur der Ersteller oder ein Super-Admin darf ihn ändern.')] class UpdateVenueTool extends Tool { + use ResolvesEntities; + public function handle(Request $request): Response { - $venue = Venue::find($request->get('id')); + $venue = $this->resolveOwnedByName($request, Venue::class, 'Veranstaltungsorte', 'venue'); - if (! $venue) { - return Response::error('Veranstaltungsort nicht gefunden.'); + if ($venue instanceof Response) { + return $venue; } $user = $request->user(); @@ -30,6 +34,10 @@ class UpdateVenueTool extends Tool return Response::error('Nur der Ersteller oder ein Super-Admin darf diesen Veranstaltungsort ändern.'); } + if ($error = $this->mergeForeignKey($request, 'city', 'city_id', City::query(), 'Stadt', false)) { + return $error; + } + $validated = $request->validate((new UpdateVenueRequest)->rules()); $venue->update($validated); @@ -43,9 +51,11 @@ class UpdateVenueTool extends Tool public function schema(JsonSchema $schema): array { return [ - 'id' => $schema->integer()->description('ID des zu aktualisierenden Veranstaltungsorts.')->required(), - 'city_id' => $schema->integer()->description('ID der zugehörigen Stadt.'), - 'name' => $schema->string()->description('Name des Veranstaltungsorts.'), + 'venue' => $schema->string()->description('Name des zu ändernden Veranstaltungsorts (aus deinen Orten, siehe list-my-venues).'), + 'id' => $schema->integer()->description('Optional: ID des Veranstaltungsorts, falls bereits bekannt (Alternative zu "venue").'), + 'city' => $schema->string()->description('Name der zugehörigen Stadt (wird automatisch aufgelöst).'), + 'city_id' => $schema->integer()->description('Optional: ID der Stadt (Alternative zu "city").'), + 'name' => $schema->string()->description('Neuer Name des Veranstaltungsorts.'), 'street' => $schema->string()->description('Straße und Hausnummer des Veranstaltungsorts.'), ]; } diff --git a/tests/Feature/Mcp/NameResolutionMcpTest.php b/tests/Feature/Mcp/NameResolutionMcpTest.php new file mode 100644 index 0000000..e86b44f --- /dev/null +++ b/tests/Feature/Mcp/NameResolutionMcpTest.php @@ -0,0 +1,89 @@ +create(); + $meetup = Meetup::factory()->create(['created_by' => $user->id, 'name' => 'Einundzwanzig Ansbach']); + + EinundzwanzigServer::actingAs($user) + ->tool(CreateMeetupEventTool::class, [ + 'meetup' => 'Einundzwanzig Ansbach', + 'start' => '2026-08-01 18:00:00', + ]) + ->assertOk(); + + $this->assertDatabaseHas('meetup_events', [ + 'meetup_id' => $meetup->id, + 'created_by' => $user->id, + ]); +}); + +it('returns the list of own meetups when the meetup name is unknown', function () { + $user = User::factory()->create(); + Meetup::factory()->create(['created_by' => $user->id, 'name' => 'Einundzwanzig Ansbach']); + Meetup::factory()->create(['created_by' => $user->id, 'name' => 'Plan B Lugano']); + + EinundzwanzigServer::actingAs($user) + ->tool(CreateMeetupEventTool::class, [ + 'meetup' => 'Gibt es nicht', + 'start' => '2026-08-01 18:00:00', + ]) + ->assertHasErrors() + ->assertSee('Einundzwanzig Ansbach') + ->assertSee('Plan B Lugano'); +}); + +it('matches the meetup name case-insensitively', function () { + $user = User::factory()->create(); + $meetup = Meetup::factory()->create(['created_by' => $user->id, 'name' => 'Einundzwanzig Ansbach']); + + EinundzwanzigServer::actingAs($user) + ->tool(CreateMeetupEventTool::class, [ + 'meetup' => 'einundzwanzig ansbach', + 'start' => '2026-08-01 18:00:00', + ]) + ->assertOk(); + + $this->assertDatabaseHas('meetup_events', ['meetup_id' => $meetup->id]); +}); + +it('updates a meetup selected by name', function () { + $user = User::factory()->create(); + Meetup::factory()->create(['created_by' => $user->id, 'name' => 'Altname']); + + EinundzwanzigServer::actingAs($user) + ->tool(UpdateMeetupTool::class, ['meetup' => 'Altname', 'name' => 'Neuname']) + ->assertOk() + ->assertSee('Neuname'); + + $this->assertDatabaseHas('meetups', ['name' => 'Neuname', 'created_by' => $user->id]); +}); + +it('creates a meetup resolving the city by name', function () { + $user = User::factory()->create(); + $city = City::factory()->create(['name' => 'Ansbach']); + + EinundzwanzigServer::actingAs($user) + ->tool(CreateMeetupTool::class, ['name' => 'Einundzwanzig Ansbach', 'city' => 'Ansbach']) + ->assertOk(); + + $this->assertDatabaseHas('meetups', [ + 'name' => 'Einundzwanzig Ansbach', + 'city_id' => $city->id, + 'created_by' => $user->id, + ]); +}); + +it('reports an unknown city name when creating a meetup', function () { + EinundzwanzigServer::actingAs(User::factory()->create()) + ->tool(CreateMeetupTool::class, ['name' => 'Test', 'city' => 'Gibtsnicht']) + ->assertHasErrors() + ->assertSee('nicht gefunden'); +});