Add ResolvesEntities concern for name-based ID resolution

- 🤖 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.
This commit is contained in:
HolgerHatGarKeineNode
2026-06-08 10:35:16 +02:00
parent dc05299e5a
commit b6f05bca41
21 changed files with 485 additions and 78 deletions
+21 -5
View File
@@ -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
{
+11 -2
View File
@@ -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(),
+9 -5
View File
@@ -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").'),
];
}
}
+17 -7
View File
@@ -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.'),
+146
View File
@@ -0,0 +1,146 @@
<?php
namespace App\Mcp\Tools\Concerns;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
/**
* Löst Entitäten über ihren Namen (oder optional ihre ID) auf, damit Nutzer keine
* internen IDs kennen müssen. Bei Mehrdeutigkeit oder fehlendem Treffer wird eine
* Auswahlliste der passenden Einträge als Fehlertext zurückgegeben.
*/
trait ResolvesEntities
{
/**
* Löst einen vom authentifizierten Nutzer erstellten Datensatz auf.
*
* Die ID-Variante sucht über alle Datensätze (die Ownership-Prüfung übernimmt die
* aufrufende Gate-Policy); die Namens-Variante ist auf die eigenen Datensätze
* beschränkt und dient damit zugleich als Auswahlliste.
*
* @param class-string<Model> $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<int, Model>
*/
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 !== '';
}
}
+11 -2
View File
@@ -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.'),
];
}
+17 -7
View File
@@ -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.'),
];
}
@@ -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(),
@@ -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.'),
@@ -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").'),
];
}
}
+10 -6
View File
@@ -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.'),
+11 -2
View File
@@ -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.'),
+9 -5
View File
@@ -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").'),
];
}
}
+17 -7
View File
@@ -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.'),
@@ -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.'),
@@ -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(),
];
}
}
@@ -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.'),
+11 -2
View File
@@ -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(),
];
+9 -5
View File
@@ -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").'),
];
}
}
+17 -7
View File
@@ -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.'),
];
}