diff --git a/.env.example b/.env.example index 048d859..af0468d 100644 --- a/.env.example +++ b/.env.example @@ -61,3 +61,10 @@ AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" + +# Laravel Passport (OAuth 2.1 für den MCP-Web-Connector, z. B. Claude.ai). +# In Produktion als Deploy-Secrets setzen – auf allen Nodes identisch. Leer lassen, +# wenn die Schlüssel via `php artisan passport:keys` in storage/ liegen (persistentes +# Volume). Niemals echte Schlüssel committen (storage/oauth-*.key ist .gitignored). +PASSPORT_PRIVATE_KEY= +PASSPORT_PUBLIC_KEY= diff --git a/app/Http/Middleware/EnforcePkceS256.php b/app/Http/Middleware/EnforcePkceS256.php new file mode 100644 index 0000000..08b7e69 --- /dev/null +++ b/app/Http/Middleware/EnforcePkceS256.php @@ -0,0 +1,29 @@ +is('oauth/authorize') + && $request->filled('code_challenge') + && $request->input('code_challenge_method', 'plain') !== 'S256') { + abort(Response::HTTP_BAD_REQUEST, 'Es wird ausschließlich die PKCE-Methode "S256" unterstützt.'); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/EnsureMcpScope.php b/app/Http/Middleware/EnsureMcpScope.php new file mode 100644 index 0000000..dee2a2a --- /dev/null +++ b/app/Http/Middleware/EnsureMcpScope.php @@ -0,0 +1,28 @@ +user(); + + if ($user !== null && method_exists($user, 'tokenCan') && ! $user->tokenCan('mcp:use')) { + abort(Response::HTTP_FORBIDDEN, 'Das Token besitzt nicht den erforderlichen Scope "mcp:use".'); + } + + return $next($request); + } +} diff --git a/app/Mcp/Servers/EinundzwanzigServer.php b/app/Mcp/Servers/EinundzwanzigServer.php new file mode 100644 index 0000000..778f856 --- /dev/null +++ b/app/Mcp/Servers/EinundzwanzigServer.php @@ -0,0 +1,106 @@ +> + */ + protected array $tools = [ + // Meetups + CreateMeetupTool::class, + UpdateMeetupTool::class, + ListMyMeetupsTool::class, + ShowMyMeetupTool::class, + + // Meetup-Events + CreateMeetupEventTool::class, + UpdateMeetupEventTool::class, + ListMyMeetupEventsTool::class, + ShowMyMeetupEventTool::class, + + // Städte + CreateCityTool::class, + UpdateCityTool::class, + ListMyCitiesTool::class, + ShowMyCityTool::class, + + // Veranstaltungsorte + CreateVenueTool::class, + UpdateVenueTool::class, + ListMyVenuesTool::class, + ShowMyVenueTool::class, + + // Referenten + CreateLecturerTool::class, + UpdateLecturerTool::class, + ListMyLecturersTool::class, + ShowMyLecturerTool::class, + + // Kurse + CreateCourseTool::class, + UpdateCourseTool::class, + + // Kurs-Events + ListMyCourseEventsTool::class, + CreateCourseEventTool::class, + UpdateCourseEventTool::class, + + // Suche / Stammdaten-Lookups + SearchCitiesTool::class, + SearchVenuesTool::class, + SearchLecturersTool::class, + SearchCoursesTool::class, + ListCountriesTool::class, + ]; +} diff --git a/app/Mcp/Tools/City/CreateCityTool.php b/app/Mcp/Tools/City/CreateCityTool.php new file mode 100644 index 0000000..4daa4c0 --- /dev/null +++ b/app/Mcp/Tools/City/CreateCityTool.php @@ -0,0 +1,52 @@ +user(); + + if ($user === null || Gate::forUser($user)->denies('create', City::class)) { + return Response::error('Nicht berechtigt, eine Stadt anzulegen.'); + } + + $storeRequest = new StoreCityRequest; + + $validated = $request->validate( + $storeRequest->rules(), + $storeRequest->messages(), + ); + + $city = City::create($validated); + + return Response::json(CityResource::make($city->fresh())->resolve()); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'country_id' => $schema->integer()->description('ID des zugehörigen Landes.')->required(), + '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(), + 'population' => $schema->integer()->description('Einwohnerzahl der Stadt.'), + ]; + } +} diff --git a/app/Mcp/Tools/City/ListMyCitiesTool.php b/app/Mcp/Tools/City/ListMyCitiesTool.php new file mode 100644 index 0000000..bcaef8a --- /dev/null +++ b/app/Mcp/Tools/City/ListMyCitiesTool.php @@ -0,0 +1,33 @@ +user(); + + if ($user === null || Gate::forUser($user)->denies('viewAny', City::class)) { + return Response::error('Nicht authentifiziert.'); + } + + $cities = City::query() + ->where('created_by', $user->getAuthIdentifier()) + ->orderBy('name') + ->get(); + + return Response::json(CityResource::collection($cities)->resolve()); + } +} diff --git a/app/Mcp/Tools/City/ShowMyCityTool.php b/app/Mcp/Tools/City/ShowMyCityTool.php new file mode 100644 index 0000000..abf137b --- /dev/null +++ b/app/Mcp/Tools/City/ShowMyCityTool.php @@ -0,0 +1,46 @@ +get('id')); + + if (! $city) { + return Response::error('Stadt nicht gefunden.'); + } + + $user = $request->user(); + + if ($user === null || Gate::forUser($user)->denies('view', $city)) { + return Response::error('Nur der Ersteller oder ein Super-Admin darf diese Stadt sehen.'); + } + + return Response::json(CityResource::make($city)->resolve()); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->integer()->description('ID der Stadt.')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/City/UpdateCityTool.php b/app/Mcp/Tools/City/UpdateCityTool.php new file mode 100644 index 0000000..ca5e44e --- /dev/null +++ b/app/Mcp/Tools/City/UpdateCityTool.php @@ -0,0 +1,54 @@ +get('id')); + + if (! $city) { + return Response::error('Stadt nicht gefunden.'); + } + + $user = $request->user(); + + if ($user === null || Gate::forUser($user)->denies('update', $city)) { + return Response::error('Nur der Ersteller oder ein Super-Admin darf diese Stadt ändern.'); + } + + $validated = $request->validate((new UpdateCityRequest)->rules()); + + $city->update($validated); + + return Response::json(CityResource::make($city->fresh())->resolve()); + } + + /** + * @return array + */ + 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.'), + '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/Course/CreateCourseTool.php b/app/Mcp/Tools/Course/CreateCourseTool.php new file mode 100644 index 0000000..097c33e --- /dev/null +++ b/app/Mcp/Tools/Course/CreateCourseTool.php @@ -0,0 +1,47 @@ +user(); + + if (! $user instanceof User || ! (bool) $user->is_lecturer) { + return Response::error('Nur Referenten (is_lecturer) dürfen Kurse anlegen.'); + } + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'lecturer_id' => ['required', 'exists:lecturers,id'], + 'description' => ['nullable', 'string'], + ]); + + $course = Course::create($validated); + + return Response::json($course->fresh()); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + 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(), + 'description' => $schema->string()->description('Beschreibung des Kurses.'), + ]; + } +} diff --git a/app/Mcp/Tools/Course/UpdateCourseTool.php b/app/Mcp/Tools/Course/UpdateCourseTool.php new file mode 100644 index 0000000..6a651f2 --- /dev/null +++ b/app/Mcp/Tools/Course/UpdateCourseTool.php @@ -0,0 +1,54 @@ +get('id')); + + if (! $course) { + return Response::error('Kurs nicht gefunden.'); + } + + $user = $request->user(); + + if (! $user instanceof User || ((int) $course->created_by !== $user->getAuthIdentifier() && ! $user->hasRole('super-admin'))) { + return Response::error('Nur der Ersteller des Kurses oder ein Super-Admin darf ihn ändern.'); + } + + $validated = $request->validate([ + 'name' => ['sometimes', 'required', 'string', 'max:255'], + 'lecturer_id' => ['sometimes', 'required', 'exists:lecturers,id'], + 'description' => ['sometimes', 'nullable', 'string'], + ]); + + $course->update($validated); + + return Response::json($course->fresh()); + } + + /** + * @return array + */ + 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.'), + 'description' => $schema->string()->description('Beschreibung des Kurses.'), + ]; + } +} diff --git a/app/Mcp/Tools/CourseEvent/CreateCourseEventTool.php b/app/Mcp/Tools/CourseEvent/CreateCourseEventTool.php new file mode 100644 index 0000000..e616883 --- /dev/null +++ b/app/Mcp/Tools/CourseEvent/CreateCourseEventTool.php @@ -0,0 +1,51 @@ +user(); + + if (! $user instanceof User || ! (bool) $user->is_lecturer) { + return Response::error('Nur Referenten (is_lecturer) dürfen Kurs-Events anlegen.'); + } + + $validated = $request->validate([ + 'course_id' => ['required', 'integer', 'exists:courses,id'], + 'venue_id' => ['required', 'integer', 'exists:venues,id'], + 'from' => ['required', 'date'], + 'to' => ['required', 'date', 'after_or_equal:from'], + 'link' => ['required', 'url', 'max:255'], + ]); + + $courseEvent = CourseEvent::create($validated); + + return Response::json($courseEvent->fresh()); + } + + /** + * @return array + */ + 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(), + '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/ListMyCourseEventsTool.php b/app/Mcp/Tools/CourseEvent/ListMyCourseEventsTool.php new file mode 100644 index 0000000..db452bd --- /dev/null +++ b/app/Mcp/Tools/CourseEvent/ListMyCourseEventsTool.php @@ -0,0 +1,48 @@ +user(); + + if ($user === null) { + return Response::error('Nicht authentifiziert.'); + } + + $events = CourseEvent::query() + ->with(['course:id,name', 'venue:id,name']) + ->where('created_by', $user->getAuthIdentifier()) + ->when( + $request->filled('course_id'), + fn ($query) => $query->where('course_id', $request->integer('course_id')) + ) + ->orderByDesc('from') + ->get(); + + return Response::json($events); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'course_id' => $schema->integer()->description('Filtert die Kurs-Events auf einen bestimmten Kurs.'), + ]; + } +} diff --git a/app/Mcp/Tools/CourseEvent/UpdateCourseEventTool.php b/app/Mcp/Tools/CourseEvent/UpdateCourseEventTool.php new file mode 100644 index 0000000..6caed8c --- /dev/null +++ b/app/Mcp/Tools/CourseEvent/UpdateCourseEventTool.php @@ -0,0 +1,58 @@ +get('id')); + + if (! $courseEvent) { + return Response::error('Kurs-Event nicht gefunden.'); + } + + $user = $request->user(); + + if (! $user instanceof User || ((int) $courseEvent->created_by !== $user->getAuthIdentifier() && ! $user->hasRole('super-admin'))) { + return Response::error('Nur der Ersteller des Kurs-Events oder ein Super-Admin darf es ändern.'); + } + + $validated = $request->validate([ + 'course_id' => ['sometimes', 'required', 'integer', 'exists:courses,id'], + 'venue_id' => ['sometimes', 'required', 'integer', 'exists:venues,id'], + 'from' => ['sometimes', 'required', 'date'], + 'to' => ['sometimes', 'required', 'date', 'after_or_equal:from'], + 'link' => ['sometimes', 'required', 'url', 'max:255'], + ]); + + $courseEvent->update($validated); + + return Response::json($courseEvent->fresh()); + } + + /** + * @return array + */ + 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.'), + '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/CreateLecturerTool.php b/app/Mcp/Tools/Lecturer/CreateLecturerTool.php new file mode 100644 index 0000000..7a70b3a --- /dev/null +++ b/app/Mcp/Tools/Lecturer/CreateLecturerTool.php @@ -0,0 +1,60 @@ +user(); + + if ($user === null || Gate::forUser($user)->denies('create', Lecturer::class)) { + return Response::error('Nicht berechtigt, einen Referenten anzulegen.'); + } + + $storeRequest = new StoreLecturerRequest; + + $validated = $request->validate( + $storeRequest->rules(), + $storeRequest->messages(), + ); + + $lecturer = Lecturer::create($validated); + + return Response::json(LecturerResource::make($lecturer->fresh())->resolve()); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'name' => $schema->string()->description('Name des Referenten.')->required(), + 'subtitle' => $schema->string()->description('Untertitel.'), + 'intro' => $schema->string()->description('Einleitungstext.'), + 'description' => $schema->string()->description('Beschreibung.'), + 'active' => $schema->boolean()->description('Aktiv.'), + 'website' => $schema->string()->description('Webseiten-URL.'), + 'twitter_username' => $schema->string()->description('Twitter/X-Benutzername.'), + 'nostr' => $schema->string()->description('Nostr-Identifier.'), + 'lightning_address' => $schema->string()->description('Lightning-Adresse.'), + 'lnurl' => $schema->string()->description('LNURL.'), + 'node_id' => $schema->string()->description('Lightning-Node-ID.'), + 'paynym' => $schema->string()->description('PayNym.'), + 'team_id' => $schema->integer()->description('ID des zugehörigen Teams.'), + ]; + } +} diff --git a/app/Mcp/Tools/Lecturer/ListMyLecturersTool.php b/app/Mcp/Tools/Lecturer/ListMyLecturersTool.php new file mode 100644 index 0000000..ab958d6 --- /dev/null +++ b/app/Mcp/Tools/Lecturer/ListMyLecturersTool.php @@ -0,0 +1,33 @@ +user(); + + if ($user === null || Gate::forUser($user)->denies('viewAny', Lecturer::class)) { + return Response::error('Nicht authentifiziert.'); + } + + $lecturers = Lecturer::query() + ->where('created_by', $user->getAuthIdentifier()) + ->orderBy('name') + ->get(); + + return Response::json(LecturerResource::collection($lecturers)->resolve()); + } +} diff --git a/app/Mcp/Tools/Lecturer/ShowMyLecturerTool.php b/app/Mcp/Tools/Lecturer/ShowMyLecturerTool.php new file mode 100644 index 0000000..a0c76a1 --- /dev/null +++ b/app/Mcp/Tools/Lecturer/ShowMyLecturerTool.php @@ -0,0 +1,46 @@ +get('id')); + + if (! $lecturer) { + return Response::error('Referent nicht gefunden.'); + } + + $user = $request->user(); + + if ($user === null || Gate::forUser($user)->denies('view', $lecturer)) { + return Response::error('Nur der Ersteller oder ein Super-Admin darf diesen Referenten sehen.'); + } + + return Response::json(LecturerResource::make($lecturer)->resolve()); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->integer()->description('ID des Referenten.')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/Lecturer/UpdateLecturerTool.php b/app/Mcp/Tools/Lecturer/UpdateLecturerTool.php new file mode 100644 index 0000000..fe25d43 --- /dev/null +++ b/app/Mcp/Tools/Lecturer/UpdateLecturerTool.php @@ -0,0 +1,62 @@ +get('id')); + + if (! $lecturer) { + return Response::error('Referent nicht gefunden.'); + } + + $user = $request->user(); + + if ($user === null || Gate::forUser($user)->denies('update', $lecturer)) { + return Response::error('Nur der Ersteller oder ein Super-Admin darf diesen Referenten ändern.'); + } + + $validated = $request->validate((new UpdateLecturerRequest)->rules()); + + $lecturer->update($validated); + + return Response::json(LecturerResource::make($lecturer->fresh())->resolve()); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->integer()->description('ID des zu aktualisierenden Referenten.')->required(), + 'name' => $schema->string()->description('Name des Referenten.'), + 'subtitle' => $schema->string()->description('Untertitel.'), + 'intro' => $schema->string()->description('Einleitungstext.'), + 'description' => $schema->string()->description('Beschreibung.'), + 'active' => $schema->boolean()->description('Aktiv.'), + 'website' => $schema->string()->description('Webseiten-URL.'), + 'twitter_username' => $schema->string()->description('Twitter/X-Benutzername.'), + 'nostr' => $schema->string()->description('Nostr-Identifier.'), + 'lightning_address' => $schema->string()->description('Lightning-Adresse.'), + 'lnurl' => $schema->string()->description('LNURL.'), + 'node_id' => $schema->string()->description('Lightning-Node-ID.'), + 'paynym' => $schema->string()->description('PayNym.'), + 'team_id' => $schema->integer()->description('ID des zugehörigen Teams.'), + ]; + } +} diff --git a/app/Mcp/Tools/Meetup/CreateMeetupTool.php b/app/Mcp/Tools/Meetup/CreateMeetupTool.php new file mode 100644 index 0000000..e00fc38 --- /dev/null +++ b/app/Mcp/Tools/Meetup/CreateMeetupTool.php @@ -0,0 +1,60 @@ +user(); + + if ($user === null || Gate::forUser($user)->denies('create', Meetup::class)) { + return Response::error('Nicht berechtigt, ein Meetup anzulegen.'); + } + + $storeRequest = new StoreMeetupRequest; + + $validated = $request->validate( + $storeRequest->rules(), + $storeRequest->messages(), + ); + + $meetup = Meetup::create($validated); + + return Response::json(MeetupResource::make($meetup->fresh())->resolve()); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + 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(), + 'intro' => $schema->string()->description('Einleitungstext.'), + 'telegram_link' => $schema->string()->description('Telegram-Gruppen-URL.'), + 'webpage' => $schema->string()->description('Webseiten-URL.'), + 'twitter_username' => $schema->string()->description('Twitter/X-Benutzername.'), + 'matrix_group' => $schema->string()->description('Matrix-Gruppe.'), + 'nostr' => $schema->string()->description('Nostr-Identifier.'), + 'simplex' => $schema->string()->description('SimpleX-Link.'), + 'signal' => $schema->string()->description('Signal-Gruppenlink.'), + 'community' => $schema->string()->description('Community-Bezeichnung.'), + 'visible_on_map' => $schema->boolean()->description('Auf der Karte sichtbar.'), + 'is_active' => $schema->boolean()->description('Aktiv.'), + ]; + } +} diff --git a/app/Mcp/Tools/Meetup/ListMyMeetupsTool.php b/app/Mcp/Tools/Meetup/ListMyMeetupsTool.php new file mode 100644 index 0000000..b28e35e --- /dev/null +++ b/app/Mcp/Tools/Meetup/ListMyMeetupsTool.php @@ -0,0 +1,33 @@ +user(); + + if ($user === null || Gate::forUser($user)->denies('viewAny', Meetup::class)) { + return Response::error('Nicht authentifiziert.'); + } + + $meetups = Meetup::query() + ->where('created_by', $user->getAuthIdentifier()) + ->orderBy('name') + ->get(); + + return Response::json(MeetupResource::collection($meetups)->resolve()); + } +} diff --git a/app/Mcp/Tools/Meetup/ShowMyMeetupTool.php b/app/Mcp/Tools/Meetup/ShowMyMeetupTool.php new file mode 100644 index 0000000..f7fd6f7 --- /dev/null +++ b/app/Mcp/Tools/Meetup/ShowMyMeetupTool.php @@ -0,0 +1,46 @@ +get('id')); + + if (! $meetup) { + return Response::error('Meetup nicht gefunden.'); + } + + $user = $request->user(); + + if ($user === null || Gate::forUser($user)->denies('view', $meetup)) { + return Response::error('Nur der Ersteller oder ein Super-Admin darf dieses Meetup sehen.'); + } + + return Response::json(MeetupResource::make($meetup)->resolve()); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->integer()->description('ID des Meetups.')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/Meetup/UpdateMeetupTool.php b/app/Mcp/Tools/Meetup/UpdateMeetupTool.php new file mode 100644 index 0000000..d2bb869 --- /dev/null +++ b/app/Mcp/Tools/Meetup/UpdateMeetupTool.php @@ -0,0 +1,62 @@ +get('id')); + + if (! $meetup) { + return Response::error('Meetup nicht gefunden.'); + } + + $user = $request->user(); + + if ($user === null || Gate::forUser($user)->denies('update', $meetup)) { + return Response::error('Nur der Ersteller oder ein Super-Admin darf dieses Meetup ändern.'); + } + + $validated = $request->validate((new UpdateMeetupRequest)->rules()); + + $meetup->update($validated); + + return Response::json(MeetupResource::make($meetup->fresh())->resolve()); + } + + /** + * @return array + */ + 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.'), + 'intro' => $schema->string()->description('Einleitungstext.'), + 'telegram_link' => $schema->string()->description('Telegram-Gruppen-URL.'), + 'webpage' => $schema->string()->description('Webseiten-URL.'), + 'twitter_username' => $schema->string()->description('Twitter/X-Benutzername.'), + 'matrix_group' => $schema->string()->description('Matrix-Gruppe.'), + 'nostr' => $schema->string()->description('Nostr-Identifier.'), + 'simplex' => $schema->string()->description('SimpleX-Link.'), + 'signal' => $schema->string()->description('Signal-Gruppenlink.'), + 'community' => $schema->string()->description('Community-Bezeichnung.'), + 'visible_on_map' => $schema->boolean()->description('Auf der Karte sichtbar.'), + 'is_active' => $schema->boolean()->description('Aktiv.'), + ]; + } +} diff --git a/app/Mcp/Tools/MeetupEvent/CreateMeetupEventTool.php b/app/Mcp/Tools/MeetupEvent/CreateMeetupEventTool.php new file mode 100644 index 0000000..1341593 --- /dev/null +++ b/app/Mcp/Tools/MeetupEvent/CreateMeetupEventTool.php @@ -0,0 +1,57 @@ +user(); + + if ($user === null || Gate::forUser($user)->denies('create', MeetupEvent::class)) { + return Response::error('Nicht berechtigt, einen Meetup-Termin anzulegen.'); + } + + $storeRequest = new StoreMeetupEventRequest; + + $validated = $request->validate( + $storeRequest->rules(), + $storeRequest->messages(), + ); + + $meetupEvent = MeetupEvent::create($validated); + + return Response::json(MeetupEventResource::make($meetupEvent->fresh())->resolve()); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'meetup_id' => $schema->integer()->description('ID des zugehörigen Meetups (vorher per search-meetups auflösen).')->required(), + '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.'), + 'link' => $schema->string()->description('Link zum Termin (URL).'), + 'recurrence_type' => $schema->string()->description('Wiederholungstyp.'), + 'recurrence_day_of_week' => $schema->string()->description('Wochentag der Wiederholung.'), + 'recurrence_day_position' => $schema->string()->description('Position des Wochentags im Monat.'), + 'recurrence_interval' => $schema->integer()->description('Wiederholungsintervall.'), + 'recurrence_end_date' => $schema->string()->description('Enddatum der Wiederholung.'), + ]; + } +} diff --git a/app/Mcp/Tools/MeetupEvent/ListMyMeetupEventsTool.php b/app/Mcp/Tools/MeetupEvent/ListMyMeetupEventsTool.php new file mode 100644 index 0000000..d275e44 --- /dev/null +++ b/app/Mcp/Tools/MeetupEvent/ListMyMeetupEventsTool.php @@ -0,0 +1,33 @@ +user(); + + if ($user === null || Gate::forUser($user)->denies('viewAny', MeetupEvent::class)) { + return Response::error('Nicht authentifiziert.'); + } + + $meetupEvents = MeetupEvent::query() + ->where('created_by', $user->getAuthIdentifier()) + ->orderByDesc('start') + ->get(); + + return Response::json(MeetupEventResource::collection($meetupEvents)->resolve()); + } +} diff --git a/app/Mcp/Tools/MeetupEvent/ShowMyMeetupEventTool.php b/app/Mcp/Tools/MeetupEvent/ShowMyMeetupEventTool.php new file mode 100644 index 0000000..1d8ec00 --- /dev/null +++ b/app/Mcp/Tools/MeetupEvent/ShowMyMeetupEventTool.php @@ -0,0 +1,46 @@ +get('id')); + + if (! $meetupEvent) { + return Response::error('Meetup-Termin nicht gefunden.'); + } + + $user = $request->user(); + + if ($user === null || Gate::forUser($user)->denies('view', $meetupEvent)) { + return Response::error('Nur der Ersteller oder ein Super-Admin darf diesen Meetup-Termin sehen.'); + } + + return Response::json(MeetupEventResource::make($meetupEvent)->resolve()); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->integer()->description('ID des Meetup-Termins.')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/MeetupEvent/UpdateMeetupEventTool.php b/app/Mcp/Tools/MeetupEvent/UpdateMeetupEventTool.php new file mode 100644 index 0000000..8d6e669 --- /dev/null +++ b/app/Mcp/Tools/MeetupEvent/UpdateMeetupEventTool.php @@ -0,0 +1,59 @@ +get('id')); + + if (! $meetupEvent) { + return Response::error('Meetup-Termin nicht gefunden.'); + } + + $user = $request->user(); + + if ($user === null || Gate::forUser($user)->denies('update', $meetupEvent)) { + return Response::error('Nur der Ersteller oder ein Super-Admin darf diesen Meetup-Termin ändern.'); + } + + $validated = $request->validate((new UpdateMeetupEventRequest)->rules()); + + $meetupEvent->update($validated); + + return Response::json(MeetupEventResource::make($meetupEvent->fresh())->resolve()); + } + + /** + * @return array + */ + 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.'), + '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.'), + 'link' => $schema->string()->description('Link zum Termin (URL).'), + 'recurrence_type' => $schema->string()->description('Wiederholungstyp.'), + 'recurrence_day_of_week' => $schema->string()->description('Wochentag der Wiederholung.'), + 'recurrence_day_position' => $schema->string()->description('Position des Wochentags im Monat.'), + 'recurrence_interval' => $schema->integer()->description('Wiederholungsintervall.'), + 'recurrence_end_date' => $schema->string()->description('Enddatum der Wiederholung.'), + ]; + } +} diff --git a/app/Mcp/Tools/Search/ListCountriesTool.php b/app/Mcp/Tools/Search/ListCountriesTool.php new file mode 100644 index 0000000..e958de7 --- /dev/null +++ b/app/Mcp/Tools/Search/ListCountriesTool.php @@ -0,0 +1,52 @@ +get('search'); + + $countries = Country::query() + ->select('id', 'name', 'code') + ->orderBy('name') + ->when( + $search, + fn (Builder $query) => $query + ->where('name', 'ilike', "%{$search}%") + ->orWhere('code', 'ilike', "%{$search}%"), + ) + ->limit(10) + ->get() + ->map(function (Country $country) { + $country->flag = asset('vendor/blade-country-flags/4x3-'.$country->code.'.svg'); + + return $country; + }); + + return Response::json($countries->values()); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'search' => $schema->string()->description('Suche in Name oder Code (Ländercode).'), + ]; + } +} diff --git a/app/Mcp/Tools/Search/SearchCitiesTool.php b/app/Mcp/Tools/Search/SearchCitiesTool.php new file mode 100644 index 0000000..4bfac3d --- /dev/null +++ b/app/Mcp/Tools/Search/SearchCitiesTool.php @@ -0,0 +1,47 @@ +get('search'); + + $cities = City::query() + ->with(['country:id,name']) + ->select('id', 'name', 'country_id') + ->orderBy('name') + ->when( + $search, + fn (Builder $query) => $query + ->where('name', 'ilike', "%{$search}%") + ) + ->limit(10) + ->get(); + + return Response::json($cities->values()); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'search' => $schema->string()->description('Teilstring-Suche im Namen der Stadt.'), + ]; + } +} diff --git a/app/Mcp/Tools/Search/SearchCoursesTool.php b/app/Mcp/Tools/Search/SearchCoursesTool.php new file mode 100644 index 0000000..4769171 --- /dev/null +++ b/app/Mcp/Tools/Search/SearchCoursesTool.php @@ -0,0 +1,56 @@ +get('search'); + $userId = $request->get('user_id'); + + $courses = Course::query() + ->select('id', 'name') + ->orderBy('name') + ->when($userId !== null, + fn (Builder $query) => $query->where('created_by', (int) $userId)) + ->when( + $search, + fn (Builder $query) => $query + ->where('name', 'ilike', "%{$search}%") + ) + ->limit(10) + ->get() + ->map(function (Course $course) { + $course->image = $course->getFirstMediaUrl('logo', + 'thumb'); + + return $course; + }); + + return Response::json($courses->values()); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'search' => $schema->string()->description('Teilstring-Suche im Namen des Kurses.'), + 'user_id' => $schema->integer()->description('Filtert die Kurse nach ihrem Ersteller.'), + ]; + } +} diff --git a/app/Mcp/Tools/Search/SearchLecturersTool.php b/app/Mcp/Tools/Search/SearchLecturersTool.php new file mode 100644 index 0000000..8a2369d --- /dev/null +++ b/app/Mcp/Tools/Search/SearchLecturersTool.php @@ -0,0 +1,52 @@ +get('search'); + + $lecturers = Lecturer::query() + ->select('id', 'name') + ->orderBy('name') + ->when( + $search, + fn (Builder $query) => $query + ->where('name', 'ilike', "%{$search}%") + ) + ->limit(10) + ->get() + ->map(function (Lecturer $lecturer) { + $lecturer->image = $lecturer->getFirstMediaUrl('avatar', + 'thumb'); + + return $lecturer; + }); + + return Response::json($lecturers->values()); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'search' => $schema->string()->description('Teilstring-Suche im Namen.'), + ]; + } +} diff --git a/app/Mcp/Tools/Search/SearchVenuesTool.php b/app/Mcp/Tools/Search/SearchVenuesTool.php new file mode 100644 index 0000000..c089d71 --- /dev/null +++ b/app/Mcp/Tools/Search/SearchVenuesTool.php @@ -0,0 +1,53 @@ +get('search'); + + $venues = Venue::query() + ->with(['city:id,name,country_id', 'city.country:id,name,code']) + ->select('id', 'name', 'city_id') + ->orderBy('name') + ->when( + $search, + fn (Builder $query) => $query + ->where('name', 'ilike', "%{$search}%") + ) + ->limit(10) + ->get() + ->map(function (Venue $venue) { + $venue->flag = asset('vendor/blade-country-flags/4x3-'.$venue->city->country->code.'.svg'); + $venue->description = $venue->city->name.', '.$venue->street; + + return $venue; + }); + + return Response::json($venues->values()); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'search' => $schema->string()->description('Teilstring-Suche im Namen des Veranstaltungsortes.'), + ]; + } +} diff --git a/app/Mcp/Tools/Venue/CreateVenueTool.php b/app/Mcp/Tools/Venue/CreateVenueTool.php new file mode 100644 index 0000000..d988475 --- /dev/null +++ b/app/Mcp/Tools/Venue/CreateVenueTool.php @@ -0,0 +1,50 @@ +user(); + + if ($user === null || Gate::forUser($user)->denies('create', Venue::class)) { + return Response::error('Nicht berechtigt, einen Veranstaltungsort anzulegen.'); + } + + $storeRequest = new StoreVenueRequest; + + $validated = $request->validate( + $storeRequest->rules(), + $storeRequest->messages(), + ); + + $venue = Venue::create($validated); + + return Response::json(VenueResource::make($venue->fresh())->resolve()); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'city_id' => $schema->integer()->description('ID der zugehörigen Stadt (vorher per search-cities auflösen).')->required(), + '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/ListMyVenuesTool.php b/app/Mcp/Tools/Venue/ListMyVenuesTool.php new file mode 100644 index 0000000..bb9a645 --- /dev/null +++ b/app/Mcp/Tools/Venue/ListMyVenuesTool.php @@ -0,0 +1,33 @@ +user(); + + if ($user === null || Gate::forUser($user)->denies('viewAny', Venue::class)) { + return Response::error('Nicht authentifiziert.'); + } + + $venues = Venue::query() + ->where('created_by', $user->getAuthIdentifier()) + ->orderBy('name') + ->get(); + + return Response::json(VenueResource::collection($venues)->resolve()); + } +} diff --git a/app/Mcp/Tools/Venue/ShowMyVenueTool.php b/app/Mcp/Tools/Venue/ShowMyVenueTool.php new file mode 100644 index 0000000..24dc795 --- /dev/null +++ b/app/Mcp/Tools/Venue/ShowMyVenueTool.php @@ -0,0 +1,46 @@ +get('id')); + + if (! $venue) { + return Response::error('Veranstaltungsort nicht gefunden.'); + } + + $user = $request->user(); + + if ($user === null || Gate::forUser($user)->denies('view', $venue)) { + return Response::error('Nur der Ersteller oder ein Super-Admin darf diesen Veranstaltungsort sehen.'); + } + + return Response::json(VenueResource::make($venue)->resolve()); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->integer()->description('ID des Veranstaltungsorts.')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/Venue/UpdateVenueTool.php b/app/Mcp/Tools/Venue/UpdateVenueTool.php new file mode 100644 index 0000000..f2a9d4f --- /dev/null +++ b/app/Mcp/Tools/Venue/UpdateVenueTool.php @@ -0,0 +1,52 @@ +get('id')); + + if (! $venue) { + return Response::error('Veranstaltungsort nicht gefunden.'); + } + + $user = $request->user(); + + if ($user === null || Gate::forUser($user)->denies('update', $venue)) { + return Response::error('Nur der Ersteller oder ein Super-Admin darf diesen Veranstaltungsort ändern.'); + } + + $validated = $request->validate((new UpdateVenueRequest)->rules()); + + $venue->update($validated); + + return Response::json(VenueResource::make($venue->fresh())->resolve()); + } + + /** + * @return array + */ + 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.'), + 'street' => $schema->string()->description('Straße und Hausnummer des Veranstaltungsorts.'), + ]; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 3d76dd5..d7a1a25 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -17,6 +17,7 @@ use Illuminate\Support\Facades\URL; use Illuminate\Support\ServiceProvider; use Laravel\Nightwatch\Facades\Nightwatch; use Laravel\Nightwatch\Http\Middleware\Sample; +use Laravel\Passport\Passport; use Livewire\Livewire; class AppServiceProvider extends ServiceProvider @@ -40,6 +41,15 @@ class AppServiceProvider extends ServiceProvider Gate::define('viewApiDocs', fn (?Authenticatable $user = null): bool => true); + // OAuth-2.1-Flow des MCP-Servers (Claude.ai Web-Connector). + Passport::authorizationView(fn ($parameters) => view('mcp.authorize', $parameters)); + + // Kurze Access-Token-Lebensdauer mit Refresh-Rotation begrenzt den Schaden eines + // geleakten Tokens (öffentliche PKCE-Clients ohne Client-Secret). Passport-Default + // wäre sonst 1 Jahr für Access- UND Refresh-Token. + Passport::tokensExpireIn(now()->addHours(8)); + Passport::refreshTokensExpireIn(now()->addDays(14)); + if ($this->app->environment('production')) { URL::forceScheme('https'); } diff --git a/bootstrap/app.php b/bootstrap/app.php index 78da8a0..f516b8c 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,6 +1,7 @@ withExceptions(function (Exceptions $exceptions) { diff --git a/composer.json b/composer.json index 7d233f3..00c3d80 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,9 @@ "ezadr/lnurl-php": "^1.0", "laravel/framework": "^13.0", "laravel/horizon": "^5.40", + "laravel/mcp": "^0.7.0", "laravel/nightwatch": "^1.18", + "laravel/passport": "^13.7", "laravel/sanctum": "^4.0", "laravel/tinker": "^3.0", "league/glide": "^3.0", diff --git a/composer.lock b/composer.lock index 03483c6..c41b5aa 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "470341cb1de08f2116075c47f24dd54a", + "content-hash": "64aa91588dbb4323f6971c6e981e77d0", "packages": [ { "name": "akuechler/laravel-geoly", @@ -720,6 +720,73 @@ ], "time": "2026-06-02T14:43:17+00:00" }, + { + "name": "defuse/php-encryption", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/defuse/php-encryption.git", + "reference": "f53396c2d34225064647a05ca76c1da9d99e5828" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/defuse/php-encryption/zipball/f53396c2d34225064647a05ca76c1da9d99e5828", + "reference": "f53396c2d34225064647a05ca76c1da9d99e5828", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "paragonie/random_compat": ">= 2", + "php": ">=5.6.0" + }, + "require-dev": { + "phpunit/phpunit": "^5|^6|^7|^8|^9|^10", + "yoast/phpunit-polyfills": "^2.0.0" + }, + "bin": [ + "bin/generate-defuse-key" + ], + "type": "library", + "autoload": { + "psr-4": { + "Defuse\\Crypto\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Hornby", + "email": "taylor@defuse.ca", + "homepage": "https://defuse.ca/" + }, + { + "name": "Scott Arciszewski", + "email": "info@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "Secure PHP Encryption Library", + "keywords": [ + "aes", + "authenticated encryption", + "cipher", + "crypto", + "cryptography", + "encrypt", + "encryption", + "openssl", + "security", + "symmetric key cryptography" + ], + "support": { + "issues": "https://github.com/defuse/php-encryption/issues", + "source": "https://github.com/defuse/php-encryption/tree/v2.4.0" + }, + "time": "2023-06-19T06:10:36+00:00" + }, { "name": "dflydev/dot-access-data", "version": "v3.0.3", @@ -1270,6 +1337,70 @@ }, "time": "2022-05-08T12:55:38+00:00" }, + { + "name": "firebase/php-jwt", + "version": "v7.0.5", + "source": { + "type": "git", + "url": "https://github.com/googleapis/php-jwt.git", + "reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/googleapis/php-jwt/zipball/47ad26bab5e7c70ae8a6f08ed25ff83631121380", + "reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4", + "phpfastcache/phpfastcache": "^9.2", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" + }, + "type": "library", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/firebase/php-jwt", + "keywords": [ + "jwt", + "php" + ], + "support": { + "issues": "https://github.com/googleapis/php-jwt/issues", + "source": "https://github.com/googleapis/php-jwt/tree/v7.0.5" + }, + "time": "2026-04-01T20:38:03+00:00" + }, { "name": "fruitcake/php-cors", "version": "v1.4.0", @@ -2348,6 +2479,79 @@ }, "time": "2026-05-20T12:12:55+00:00" }, + { + "name": "laravel/mcp", + "version": "v0.7.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/mcp.git", + "reference": "3513b4feca5f1678be4d2261dcfa8e456436d02a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/mcp/zipball/3513b4feca5f1678be4d2261dcfa8e456436d02a", + "reference": "3513b4feca5f1678be4d2261dcfa8e456436d02a", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "illuminate/console": "^11.45.3|^12.41.1|^13.0", + "illuminate/container": "^11.45.3|^12.41.1|^13.0", + "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", + "illuminate/http": "^11.45.3|^12.41.1|^13.0", + "illuminate/json-schema": "^12.41.1|^13.0", + "illuminate/routing": "^11.45.3|^12.41.1|^13.0", + "illuminate/support": "^11.45.3|^12.41.1|^13.0", + "illuminate/validation": "^11.45.3|^12.41.1|^13.0", + "php": "^8.2" + }, + "require-dev": { + "laravel/pint": "^1.20", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "pestphp/pest": "^3.8.5|^4.3.2", + "phpstan/phpstan": "^2.1.27", + "rector/rector": "^2.2.4" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp" + }, + "providers": [ + "Laravel\\Mcp\\Server\\McpServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Mcp\\": "src/", + "Laravel\\Mcp\\Server\\": "src/Server/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Rapidly build MCP servers for your Laravel applications.", + "homepage": "https://github.com/laravel/mcp", + "keywords": [ + "laravel", + "mcp" + ], + "support": { + "issues": "https://github.com/laravel/mcp/issues", + "source": "https://github.com/laravel/mcp" + }, + "time": "2026-04-21T10:23:03+00:00" + }, { "name": "laravel/nightwatch", "version": "v1.27.0", @@ -2442,6 +2646,81 @@ }, "time": "2026-05-21T01:59:31+00:00" }, + { + "name": "laravel/passport", + "version": "v13.7.5", + "source": { + "type": "git", + "url": "https://github.com/laravel/passport.git", + "reference": "90053dc4ba681c076855779250109bb624f961f6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/passport/zipball/90053dc4ba681c076855779250109bb624f961f6", + "reference": "90053dc4ba681c076855779250109bb624f961f6", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-openssl": "*", + "firebase/php-jwt": "^6.4|^7.0", + "illuminate/auth": "^11.35|^12.0|^13.0", + "illuminate/console": "^11.35|^12.0|^13.0", + "illuminate/container": "^11.35|^12.0|^13.0", + "illuminate/contracts": "^11.35|^12.0|^13.0", + "illuminate/cookie": "^11.35|^12.0|^13.0", + "illuminate/database": "^11.35|^12.0|^13.0", + "illuminate/encryption": "^11.35|^12.0|^13.0", + "illuminate/http": "^11.35|^12.0|^13.0", + "illuminate/support": "^11.35|^12.0|^13.0", + "league/oauth2-server": "^9.2", + "php": "^8.2", + "php-http/discovery": "^1.20", + "phpseclib/phpseclib": "^3.0", + "psr/http-factory-implementation": "*", + "symfony/console": "^7.1|^8.0", + "symfony/psr-http-message-bridge": "^7.1|^8.0" + }, + "require-dev": { + "orchestra/testbench": "^9.15|^10.8|^11.0", + "phpstan/phpstan": "^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Passport\\PassportServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Passport\\": "src/", + "Laravel\\Passport\\Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Passport provides OAuth2 server support to Laravel.", + "keywords": [ + "laravel", + "oauth", + "passport" + ], + "support": { + "issues": "https://github.com/laravel/passport/issues", + "source": "https://github.com/laravel/passport" + }, + "time": "2026-04-16T14:00:29+00:00" + }, { "name": "laravel/prompts", "version": "v0.3.18", @@ -2750,6 +3029,143 @@ }, "time": "2026-03-17T14:54:13+00:00" }, + { + "name": "lcobucci/clock", + "version": "3.6.0", + "source": { + "type": "git", + "url": "https://github.com/lcobucci/clock.git", + "reference": "4cdd88f761e9be9095ccbedf3e08d61ae216c643" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lcobucci/clock/zipball/4cdd88f761e9be9095ccbedf3e08d61ae216c643", + "reference": "4cdd88f761e9be9095ccbedf3e08d61ae216c643", + "shasum": "" + }, + "require": { + "php": "~8.4.0 || ~8.5.0", + "psr/clock": "^1.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "infection/infection": "^0.32", + "lcobucci/coding-standard": "^12.0", + "phpstan/extension-installer": "^1.3.1", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Lcobucci\\Clock\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Luís Cobucci", + "email": "lcobucci@gmail.com" + } + ], + "description": "Yet another clock abstraction", + "support": { + "issues": "https://github.com/lcobucci/clock/issues", + "source": "https://github.com/lcobucci/clock/tree/3.6.0" + }, + "funding": [ + { + "url": "https://github.com/lcobucci", + "type": "github" + }, + { + "url": "https://www.patreon.com/lcobucci", + "type": "patreon" + } + ], + "time": "2026-04-13T21:30:16+00:00" + }, + { + "name": "lcobucci/jwt", + "version": "5.6.0", + "source": { + "type": "git", + "url": "https://github.com/lcobucci/jwt.git", + "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lcobucci/jwt/zipball/bb3e9f21e4196e8afc41def81ef649c164bca25e", + "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "ext-sodium": "*", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/clock": "^1.0" + }, + "require-dev": { + "infection/infection": "^0.29", + "lcobucci/clock": "^3.2", + "lcobucci/coding-standard": "^11.0", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.2", + "phpstan/phpstan": "^1.10.7", + "phpstan/phpstan-deprecation-rules": "^1.1.3", + "phpstan/phpstan-phpunit": "^1.3.10", + "phpstan/phpstan-strict-rules": "^1.5.0", + "phpunit/phpunit": "^11.1" + }, + "suggest": { + "lcobucci/clock": ">= 3.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Lcobucci\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Luís Cobucci", + "email": "lcobucci@gmail.com", + "role": "Developer" + } + ], + "description": "A simple library to work with JSON Web Token and JSON Web Signature", + "keywords": [ + "JWS", + "jwt" + ], + "support": { + "issues": "https://github.com/lcobucci/jwt/issues", + "source": "https://github.com/lcobucci/jwt/tree/5.6.0" + }, + "funding": [ + { + "url": "https://github.com/lcobucci", + "type": "github" + }, + { + "url": "https://www.patreon.com/lcobucci", + "type": "patreon" + } + ], + "time": "2025-10-17T11:30:53+00:00" + }, { "name": "league/commonmark", "version": "2.8.2", @@ -2939,6 +3355,65 @@ ], "time": "2022-12-11T20:36:23+00:00" }, + { + "name": "league/event", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/event.git", + "reference": "ec38ff7ea10cad7d99a79ac937fbcffb9334c210" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/event/zipball/ec38ff7ea10cad7d99a79ac937fbcffb9334c210", + "reference": "ec38ff7ea10cad7d99a79ac937fbcffb9334c210", + "shasum": "" + }, + "require": { + "php": ">=7.2.0", + "psr/event-dispatcher": "^1.0" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.16", + "phpstan/phpstan": "^0.12.45", + "phpunit/phpunit": "^8.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Event\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frenky.net" + } + ], + "description": "Event package", + "keywords": [ + "emitter", + "event", + "listener" + ], + "support": { + "issues": "https://github.com/thephpleague/event/issues", + "source": "https://github.com/thephpleague/event/tree/3.0.3" + }, + "time": "2024-09-04T16:06:53+00:00" + }, { "name": "league/flysystem", "version": "3.34.0", @@ -3193,6 +3668,102 @@ ], "time": "2024-09-21T08:32:55+00:00" }, + { + "name": "league/oauth2-server", + "version": "9.3.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/oauth2-server.git", + "reference": "d8e2f39f645a82b207bbac441694d6e6079357cb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/oauth2-server/zipball/d8e2f39f645a82b207bbac441694d6e6079357cb", + "reference": "d8e2f39f645a82b207bbac441694d6e6079357cb", + "shasum": "" + }, + "require": { + "defuse/php-encryption": "^2.4", + "ext-json": "*", + "ext-openssl": "*", + "lcobucci/clock": "^2.3 || ^3.0", + "lcobucci/jwt": "^5.0", + "league/event": "^3.0", + "league/uri": "^7.0", + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/http-message": "^2.0", + "psr/http-server-middleware": "^1.0" + }, + "replace": { + "league/oauth2server": "*", + "lncd/oauth2": "*" + }, + "require-dev": { + "laminas/laminas-diactoros": "^3.5", + "php-parallel-lint/php-parallel-lint": "^1.3.2", + "phpstan/extension-installer": "^1.3.1", + "phpstan/phpstan": "^1.12|^2.0", + "phpstan/phpstan-deprecation-rules": "^1.1.4|^2.0", + "phpstan/phpstan-phpunit": "^1.3.15|^2.0", + "phpstan/phpstan-strict-rules": "^1.5.2|^2.0", + "phpunit/phpunit": "^10.5|^11.5|^12.0", + "roave/security-advisories": "dev-master", + "slevomat/coding-standard": "^8.14.1", + "squizlabs/php_codesniffer": "^3.8" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\OAuth2\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alex Bilbie", + "email": "hello@alexbilbie.com", + "homepage": "http://www.alexbilbie.com", + "role": "Developer" + }, + { + "name": "Andy Millington", + "email": "andrew@noexceptions.io", + "homepage": "https://www.noexceptions.io", + "role": "Developer" + } + ], + "description": "A lightweight and powerful OAuth 2.0 authorization and resource server library with support for all the core specification grants. This library will allow you to secure your API with OAuth and allow your applications users to approve apps that want to access their data from your API.", + "homepage": "https://oauth2.thephpleague.com/", + "keywords": [ + "Authentication", + "api", + "auth", + "authorisation", + "authorization", + "oauth", + "oauth 2", + "oauth 2.0", + "oauth2", + "protect", + "resource", + "secure", + "server" + ], + "support": { + "issues": "https://github.com/thephpleague/oauth2-server/issues", + "source": "https://github.com/thephpleague/oauth2-server/tree/9.3.0" + }, + "funding": [ + { + "url": "https://github.com/sephster", + "type": "github" + } + ], + "time": "2025-11-25T22:51:15+00:00" + }, { "name": "league/uri", "version": "7.8.1", @@ -4644,6 +5215,56 @@ }, "time": "2025-07-19T01:25:49+00:00" }, + { + "name": "paragonie/random_compat", + "version": "v9.99.100", + "source": { + "type": "git", + "url": "https://github.com/paragonie/random_compat.git", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a", + "shasum": "" + }, + "require": { + "php": ">= 7" + }, + "require-dev": { + "phpunit/phpunit": "4.*|5.*", + "vimeo/psalm": "^1" + }, + "suggest": { + "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", + "keywords": [ + "csprng", + "polyfill", + "pseudorandom", + "random" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/random_compat/issues", + "source": "https://github.com/paragonie/random_compat" + }, + "time": "2020-10-15T08:29:30+00:00" + }, { "name": "paragonie/sodium_compat", "version": "v2.5.0", @@ -4740,6 +5361,85 @@ }, "time": "2025-12-30T16:12:18+00:00" }, + { + "name": "php-http/discovery", + "version": "1.20.0", + "source": { + "type": "git", + "url": "https://github.com/php-http/discovery.git", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0|^2.0", + "php": "^7.1 || ^8.0" + }, + "conflict": { + "nyholm/psr7": "<1.0", + "zendframework/zend-diactoros": "*" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "*", + "psr/http-factory-implementation": "*", + "psr/http-message-implementation": "*" + }, + "require-dev": { + "composer/composer": "^1.0.2|^2.0", + "graham-campbell/phpspec-skip-example-extension": "^5.0", + "php-http/httplug": "^1.0 || ^2.0", + "php-http/message-factory": "^1.0", + "phpspec/phpspec": "^5.1 || ^6.1 || ^7.3", + "sebastian/comparator": "^3.0.5 || ^4.0.8", + "symfony/phpunit-bridge": "^6.4.4 || ^7.0.1" + }, + "type": "composer-plugin", + "extra": { + "class": "Http\\Discovery\\Composer\\Plugin", + "plugin-optional": true + }, + "autoload": { + "psr-4": { + "Http\\Discovery\\": "src/" + }, + "exclude-from-classmap": [ + "src/Composer/Plugin.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations", + "homepage": "http://php-http.org", + "keywords": [ + "adapter", + "client", + "discovery", + "factory", + "http", + "message", + "psr17", + "psr7" + ], + "support": { + "issues": "https://github.com/php-http/discovery/issues", + "source": "https://github.com/php-http/discovery/tree/1.20.0" + }, + "time": "2024-10-02T11:20:13+00:00" + }, { "name": "phpoption/phpoption", "version": "1.9.5", @@ -4815,6 +5515,116 @@ ], "time": "2025-12-27T19:41:33+00:00" }, + { + "name": "phpseclib/phpseclib", + "version": "3.0.52", + "source": { + "type": "git", + "url": "https://github.com/phpseclib/phpseclib.git", + "reference": "2adaefc83df2ec548558307690f376dd7d4f4fce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/2adaefc83df2ec548558307690f376dd7d4f4fce", + "reference": "2adaefc83df2ec548558307690f376dd7d4f4fce", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1|^2|^3", + "paragonie/random_compat": "^1.4|^2.0|^9.99.99", + "php": ">=5.6.1" + }, + "require-dev": { + "phpunit/phpunit": "*" + }, + "suggest": { + "ext-dom": "Install the DOM extension to load XML formatted public keys.", + "ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.", + "ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.", + "ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.", + "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations." + }, + "type": "library", + "autoload": { + "files": [ + "phpseclib/bootstrap.php" + ], + "psr-4": { + "phpseclib3\\": "phpseclib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jim Wigginton", + "email": "terrafrost@php.net", + "role": "Lead Developer" + }, + { + "name": "Patrick Monnerat", + "email": "pm@datasphere.ch", + "role": "Developer" + }, + { + "name": "Andreas Fischer", + "email": "bantu@phpbb.com", + "role": "Developer" + }, + { + "name": "Hans-Jürgen Petrich", + "email": "petrich@tronic-media.com", + "role": "Developer" + }, + { + "name": "Graham Campbell", + "email": "graham@alt-three.com", + "role": "Developer" + } + ], + "description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.", + "homepage": "http://phpseclib.sourceforge.net", + "keywords": [ + "BigInteger", + "aes", + "asn.1", + "asn1", + "blowfish", + "crypto", + "cryptography", + "encryption", + "rsa", + "security", + "sftp", + "signature", + "signing", + "ssh", + "twofish", + "x.509", + "x509" + ], + "support": { + "issues": "https://github.com/phpseclib/phpseclib/issues", + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.52" + }, + "funding": [ + { + "url": "https://github.com/terrafrost", + "type": "github" + }, + { + "url": "https://www.patreon.com/phpseclib", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib", + "type": "tidelift" + } + ], + "time": "2026-04-27T07:02:15+00:00" + }, { "name": "phpstan/phpdoc-parser", "version": "2.3.2", @@ -5693,6 +6503,119 @@ }, "time": "2023-04-04T09:54:51+00:00" }, + { + "name": "psr/http-server-handler", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-handler.git", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-handler/zipball/84c4fb66179be4caaf8e97bd239203245302e7d4", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side request handler", + "keywords": [ + "handler", + "http", + "http-interop", + "psr", + "psr-15", + "psr-7", + "request", + "response", + "server" + ], + "support": { + "source": "https://github.com/php-fig/http-server-handler/tree/1.0.2" + }, + "time": "2023-04-10T20:06:20+00:00" + }, + { + "name": "psr/http-server-middleware", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-middleware.git", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-middleware/zipball/c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0", + "psr/http-server-handler": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side middleware", + "keywords": [ + "http", + "http-interop", + "middleware", + "psr", + "psr-15", + "psr-7", + "request", + "response" + ], + "support": { + "issues": "https://github.com/php-fig/http-server-middleware/issues", + "source": "https://github.com/php-fig/http-server-middleware/tree/1.0.2" + }, + "time": "2023-04-11T06:14:47+00:00" + }, { "name": "psr/log", "version": "3.0.2", @@ -9964,6 +10887,93 @@ ], "time": "2026-05-29T05:06:50+00:00" }, + { + "name": "symfony/psr-http-message-bridge", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/psr-http-message-bridge.git", + "reference": "67fd34de15ded1763aa1e330fe345f080a94022c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/67fd34de15ded1763aa1e330fe345f080a94022c", + "reference": "67fd34de15ded1763aa1e330fe345f080a94022c", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/http-message": "^1.0|^2.0", + "symfony/http-foundation": "^7.4|^8.0" + }, + "conflict": { + "php-http/discovery": "<1.15" + }, + "require-dev": { + "nyholm/psr7": "^1.1", + "php-http/discovery": "^1.15", + "psr/log": "^1.1.4|^2|^3", + "symfony/browser-kit": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/runtime": "^7.4|^8.0" + }, + "type": "symfony-bridge", + "autoload": { + "psr-4": { + "Symfony\\Bridge\\PsrHttpMessage\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "PSR HTTP message bridge", + "homepage": "https://symfony.com", + "keywords": [ + "http", + "http-message", + "psr-17", + "psr-7" + ], + "support": { + "source": "https://github.com/symfony/psr-http-message-bridge/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, { "name": "symfony/routing", "version": "v8.1.0", @@ -12796,79 +13806,6 @@ }, "time": "2026-05-19T20:09:50+00:00" }, - { - "name": "laravel/mcp", - "version": "v0.7.0", - "source": { - "type": "git", - "url": "https://github.com/laravel/mcp.git", - "reference": "3513b4feca5f1678be4d2261dcfa8e456436d02a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/laravel/mcp/zipball/3513b4feca5f1678be4d2261dcfa8e456436d02a", - "reference": "3513b4feca5f1678be4d2261dcfa8e456436d02a", - "shasum": "" - }, - "require": { - "ext-json": "*", - "ext-mbstring": "*", - "illuminate/console": "^11.45.3|^12.41.1|^13.0", - "illuminate/container": "^11.45.3|^12.41.1|^13.0", - "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", - "illuminate/http": "^11.45.3|^12.41.1|^13.0", - "illuminate/json-schema": "^12.41.1|^13.0", - "illuminate/routing": "^11.45.3|^12.41.1|^13.0", - "illuminate/support": "^11.45.3|^12.41.1|^13.0", - "illuminate/validation": "^11.45.3|^12.41.1|^13.0", - "php": "^8.2" - }, - "require-dev": { - "laravel/pint": "^1.20", - "orchestra/testbench": "^9.15|^10.8|^11.0", - "pestphp/pest": "^3.8.5|^4.3.2", - "phpstan/phpstan": "^2.1.27", - "rector/rector": "^2.2.4" - }, - "type": "library", - "extra": { - "laravel": { - "aliases": { - "Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp" - }, - "providers": [ - "Laravel\\Mcp\\Server\\McpServiceProvider" - ] - } - }, - "autoload": { - "psr-4": { - "Laravel\\Mcp\\": "src/", - "Laravel\\Mcp\\Server\\": "src/Server/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Taylor Otwell", - "email": "taylor@laravel.com" - } - ], - "description": "Rapidly build MCP servers for your Laravel applications.", - "homepage": "https://github.com/laravel/mcp", - "keywords": [ - "laravel", - "mcp" - ], - "support": { - "issues": "https://github.com/laravel/mcp/issues", - "source": "https://github.com/laravel/mcp" - }, - "time": "2026-04-21T10:23:03+00:00" - }, { "name": "laravel/pail", "version": "v1.2.7", diff --git a/config/auth.php b/config/auth.php index 0ba5d5d..d834dc0 100644 --- a/config/auth.php +++ b/config/auth.php @@ -1,5 +1,7 @@ 'session', 'provider' => 'users', ], + + 'api' => [ + 'driver' => 'passport', + 'provider' => 'users', + ], ], /* @@ -62,7 +69,7 @@ return [ 'providers' => [ 'users' => [ 'driver' => 'eloquent', - 'model' => env('AUTH_MODEL', App\Models\User::class), + 'model' => env('AUTH_MODEL', User::class), ], // 'users' => [ diff --git a/config/mcp.php b/config/mcp.php new file mode 100644 index 0000000..3813260 --- /dev/null +++ b/config/mcp.php @@ -0,0 +1,56 @@ + [ + // Claude.ai / Claude Desktop Web-Connectors (OAuth 2.1 Custom Connector). + 'https://claude.ai', + 'https://claude.com', + // Lokale Entwicklung / MCP Inspector. + 'http://localhost', + ], + + /* + |-------------------------------------------------------------------------- + | Allowed Custom Schemes + |-------------------------------------------------------------------------- + | + | Native desktop OAuth clients like Cursor and VS Code use private-use URI + | schemes (RFC 8252) for redirect callbacks instead of standard schemes + | like HTTPS. Here, you may list which custom schemes you will allow. + | + */ + + 'custom_schemes' => [ + // 'claude', + // 'cursor', + // 'vscode', + ], + + /* + |-------------------------------------------------------------------------- + | Authorization Server + |-------------------------------------------------------------------------- + | + | Here you may configure the OAuth authorization server issuer identifier + | per RFC 8414. This value appears in your protected resource and auth + | server metadata endpoints. When null, this defaults to `url('/')`. + | + */ + + 'authorization_server' => null, + +]; diff --git a/config/passport.php b/config/passport.php new file mode 100644 index 0000000..aed4358 --- /dev/null +++ b/config/passport.php @@ -0,0 +1,48 @@ + 'web', + + 'middleware' => [], + + /* + |-------------------------------------------------------------------------- + | Encryption Keys + |-------------------------------------------------------------------------- + | + | Passport uses encryption keys while generating secure access tokens for + | your application. By default, the keys are stored as local files but + | can be set via environment variables when that is more convenient. + | + */ + + 'private_key' => env('PASSPORT_PRIVATE_KEY'), + + 'public_key' => env('PASSPORT_PUBLIC_KEY'), + + /* + |-------------------------------------------------------------------------- + | Passport Database Connection + |-------------------------------------------------------------------------- + | + | By default, Passport's models will utilize your application's default + | database connection. If you wish to use a different connection you + | may specify the configured name of the database connection here. + | + */ + + 'connection' => env('PASSPORT_CONNECTION'), + +]; diff --git a/database/migrations/2026_06_08_065611_create_oauth_auth_codes_table.php b/database/migrations/2026_06_08_065611_create_oauth_auth_codes_table.php new file mode 100644 index 0000000..c700b50 --- /dev/null +++ b/database/migrations/2026_06_08_065611_create_oauth_auth_codes_table.php @@ -0,0 +1,39 @@ +char('id', 80)->primary(); + $table->foreignId('user_id')->index(); + $table->foreignUuid('client_id'); + $table->text('scopes')->nullable(); + $table->boolean('revoked'); + $table->dateTime('expires_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_auth_codes'); + } + + /** + * Get the migration connection name. + */ + public function getConnection(): ?string + { + return $this->connection ?? config('passport.connection'); + } +}; diff --git a/database/migrations/2026_06_08_065612_create_oauth_access_tokens_table.php b/database/migrations/2026_06_08_065612_create_oauth_access_tokens_table.php new file mode 100644 index 0000000..3e50f7f --- /dev/null +++ b/database/migrations/2026_06_08_065612_create_oauth_access_tokens_table.php @@ -0,0 +1,41 @@ +char('id', 80)->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->foreignUuid('client_id'); + $table->string('name')->nullable(); + $table->text('scopes')->nullable(); + $table->boolean('revoked'); + $table->timestamps(); + $table->dateTime('expires_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_access_tokens'); + } + + /** + * Get the migration connection name. + */ + public function getConnection(): ?string + { + return $this->connection ?? config('passport.connection'); + } +}; diff --git a/database/migrations/2026_06_08_065613_create_oauth_refresh_tokens_table.php b/database/migrations/2026_06_08_065613_create_oauth_refresh_tokens_table.php new file mode 100644 index 0000000..afb3c55 --- /dev/null +++ b/database/migrations/2026_06_08_065613_create_oauth_refresh_tokens_table.php @@ -0,0 +1,37 @@ +char('id', 80)->primary(); + $table->char('access_token_id', 80)->index(); + $table->boolean('revoked'); + $table->dateTime('expires_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_refresh_tokens'); + } + + /** + * Get the migration connection name. + */ + public function getConnection(): ?string + { + return $this->connection ?? config('passport.connection'); + } +}; diff --git a/database/migrations/2026_06_08_065614_create_oauth_clients_table.php b/database/migrations/2026_06_08_065614_create_oauth_clients_table.php new file mode 100644 index 0000000..9794dc8 --- /dev/null +++ b/database/migrations/2026_06_08_065614_create_oauth_clients_table.php @@ -0,0 +1,42 @@ +uuid('id')->primary(); + $table->nullableMorphs('owner'); + $table->string('name'); + $table->string('secret')->nullable(); + $table->string('provider')->nullable(); + $table->text('redirect_uris'); + $table->text('grant_types'); + $table->boolean('revoked'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_clients'); + } + + /** + * Get the migration connection name. + */ + public function getConnection(): ?string + { + return $this->connection ?? config('passport.connection'); + } +}; diff --git a/database/migrations/2026_06_08_065615_create_oauth_device_codes_table.php b/database/migrations/2026_06_08_065615_create_oauth_device_codes_table.php new file mode 100644 index 0000000..ea07831 --- /dev/null +++ b/database/migrations/2026_06_08_065615_create_oauth_device_codes_table.php @@ -0,0 +1,42 @@ +char('id', 80)->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->foreignUuid('client_id')->index(); + $table->char('user_code', 8)->unique(); + $table->text('scopes'); + $table->boolean('revoked'); + $table->dateTime('user_approved_at')->nullable(); + $table->dateTime('last_polled_at')->nullable(); + $table->dateTime('expires_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_device_codes'); + } + + /** + * Get the migration connection name. + */ + public function getConnection(): ?string + { + return $this->connection ?? config('passport.connection'); + } +}; diff --git a/lang/de.json b/lang/de.json index f141279..a376893 100644 --- a/lang/de.json +++ b/lang/de.json @@ -697,5 +697,6 @@ "Nie": "", "Widerrufen": "", "Token widerrufen": "", - "Token „:name“ wirklich widerrufen? Anwendungen, die es nutzen, verlieren den Zugriff.": "" + "Token „:name“ wirklich widerrufen? Anwendungen, die es nutzen, verlieren den Zugriff.": "", + "API Dokumentation": "" } \ No newline at end of file diff --git a/lang/en.json b/lang/en.json index b6c7264..83851cb 100644 --- a/lang/en.json +++ b/lang/en.json @@ -696,5 +696,6 @@ "Nie": "", "Widerrufen": "", "Token widerrufen": "", - "Token „:name“ wirklich widerrufen? Anwendungen, die es nutzen, verlieren den Zugriff.": "" + "Token „:name“ wirklich widerrufen? Anwendungen, die es nutzen, verlieren den Zugriff.": "", + "API Dokumentation": "" } \ No newline at end of file diff --git a/lang/es.json b/lang/es.json index 48193c6..1a95a5f 100644 --- a/lang/es.json +++ b/lang/es.json @@ -697,5 +697,6 @@ "Nie": "", "Widerrufen": "", "Token widerrufen": "", - "Token „:name“ wirklich widerrufen? Anwendungen, die es nutzen, verlieren den Zugriff.": "" + "Token „:name“ wirklich widerrufen? Anwendungen, die es nutzen, verlieren den Zugriff.": "", + "API Dokumentation": "" } \ No newline at end of file diff --git a/lang/hu.json b/lang/hu.json index aa786a5..88d6bee 100644 --- a/lang/hu.json +++ b/lang/hu.json @@ -693,5 +693,6 @@ "Nie": "", "Widerrufen": "", "Token widerrufen": "", - "Token „:name“ wirklich widerrufen? Anwendungen, die es nutzen, verlieren den Zugriff.": "" + "Token „:name“ wirklich widerrufen? Anwendungen, die es nutzen, verlieren den Zugriff.": "", + "API Dokumentation": "" } \ No newline at end of file diff --git a/lang/lv.json b/lang/lv.json index de831ac..0f1da12 100644 --- a/lang/lv.json +++ b/lang/lv.json @@ -668,5 +668,6 @@ "Nie": "", "Widerrufen": "", "Token widerrufen": "", - "Token „:name“ wirklich widerrufen? Anwendungen, die es nutzen, verlieren den Zugriff.": "" + "Token „:name“ wirklich widerrufen? Anwendungen, die es nutzen, verlieren den Zugriff.": "", + "API Dokumentation": "" } \ No newline at end of file diff --git a/lang/nl.json b/lang/nl.json index 9e39bf9..aea0fac 100644 --- a/lang/nl.json +++ b/lang/nl.json @@ -695,5 +695,6 @@ "Nie": "", "Widerrufen": "", "Token widerrufen": "", - "Token „:name“ wirklich widerrufen? Anwendungen, die es nutzen, verlieren den Zugriff.": "" + "Token „:name“ wirklich widerrufen? Anwendungen, die es nutzen, verlieren den Zugriff.": "", + "API Dokumentation": "" } \ No newline at end of file diff --git a/lang/pl.json b/lang/pl.json index 73e4108..62290f2 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -691,5 +691,6 @@ "Nie": "", "Widerrufen": "", "Token widerrufen": "", - "Token „:name“ wirklich widerrufen? Anwendungen, die es nutzen, verlieren den Zugriff.": "" + "Token „:name“ wirklich widerrufen? Anwendungen, die es nutzen, verlieren den Zugriff.": "", + "API Dokumentation": "" } \ No newline at end of file diff --git a/lang/pt.json b/lang/pt.json index a6d50c9..db83e80 100644 --- a/lang/pt.json +++ b/lang/pt.json @@ -693,5 +693,6 @@ "Nie": "", "Widerrufen": "", "Token widerrufen": "", - "Token „:name“ wirklich widerrufen? Anwendungen, die es nutzen, verlieren den Zugriff.": "" + "Token „:name“ wirklich widerrufen? Anwendungen, die es nutzen, verlieren den Zugriff.": "", + "API Dokumentation": "" } \ No newline at end of file diff --git a/resources/views/mcp/authorize.blade.php b/resources/views/mcp/authorize.blade.php new file mode 100644 index 0000000..7d14787 --- /dev/null +++ b/resources/views/mcp/authorize.blade.php @@ -0,0 +1,180 @@ + + ($appearance ?? 'system') == 'dark'])> + + + + + {{-- Inline script to detect system dark mode preference and apply it immediately --}} + + + + + Authorize Application - {{ config('app.name', 'MCP Server') }} + + + + + + + + + + + + @vite(['resources/css/app.css']) + + +
+
+ +
+ +
+
+ + + + +
+ +

+ Authorize {{ $client->name }} +

+ +

+ This application will be able to:
Use available MCP functionality. +

+
+ + +
+ +
+

Logged in as:

+

{{ $user->email }}

+
+ + + @if(count($scopes) > 0) +
+

Permissions:

+ +
    + @foreach($scopes as $scope) +
  • +
    +
    +
    + + {{ $scope->description }} + +
  • + @endforeach +
+
+ @endif +
+ + +
+ +
+ @csrf + @method('DELETE') + + + + +
+ + +
+ @csrf + + + + +
+
+
+
+
+ + + + diff --git a/resources/views/vendor/mcp/components/app.blade.php b/resources/views/vendor/mcp/components/app.blade.php new file mode 100644 index 0000000..48a0874 --- /dev/null +++ b/resources/views/vendor/mcp/components/app.blade.php @@ -0,0 +1,21 @@ +@props(['title' => null]) +@php + $mcpSdk = app('mcp.sdk'); + $libraryScripts = app()->bound('mcp.library_scripts') ? app('mcp.library_scripts') : ''; +@endphp + + + + + + @if($title) + {{ $title }} + @endif + + {!! $libraryScripts !!} + {{ $head ?? '' }} + + + {{ $slot }} + + diff --git a/routes/ai.php b/routes/ai.php new file mode 100644 index 0000000..505b2ca --- /dev/null +++ b/routes/ai.php @@ -0,0 +1,37 @@ +group(function (): void { + Mcp::oauthRoutes(); +}); + +/* + * Authentifizierter MCP-Server. Spiegelt die Sanctum-geschützten API-Schreib-Endpunkte, + * damit AI-Clients Datensätze im Kontext des angemeldeten Nutzers anlegen/aktualisieren + * können (created_by wird automatisch gesetzt). + * + * Zwei Auth-Wege werden unterstützt: + * - auth:sanctum → statisches Sanctum-Token im Authorization: Bearer Header + * (Claude Code / Claude Desktop / Messages-API). + * - auth:api → Passport OAuth-2.1-Access-Token (Claude.ai Web-Connector). + * Reihenfolge ist wichtig: Sanctum lehnt fremde Tokens still ab und fällt zu Passport + * durch, während der Passport-Guard bei einem Sanctum-Token eine Exception werfen würde. + * Der zuerst erfolgreiche Guard wird verwendet; in beiden Fällen liefert auth()->id() + * den angemeldeten Nutzer für die created_by-Zuordnung. + * + * EnsureMcpScope erzwingt anschließend den Scope "mcp:use" (Passport) bzw. die + * Standard-Ability "*" (Sanctum). + */ +Mcp::web('/mcp', EinundzwanzigServer::class) + ->middleware(['auth:sanctum,api', EnsureMcpScope::class, 'throttle:60,1']); diff --git a/tests/Feature/Mcp/CityMcpToolTest.php b/tests/Feature/Mcp/CityMcpToolTest.php new file mode 100644 index 0000000..7843f8a --- /dev/null +++ b/tests/Feature/Mcp/CityMcpToolTest.php @@ -0,0 +1,73 @@ +create(); + $country = Country::factory()->create(); + + $response = EinundzwanzigServer::actingAs($user)->tool(CreateCityTool::class, [ + 'name' => 'Ansbach', + 'country_id' => $country->id, + 'longitude' => 10.5806, + 'latitude' => 49.3034, + ]); + + $response->assertOk()->assertSee('Ansbach'); + + $this->assertDatabaseHas('cities', [ + 'name' => 'Ansbach', + 'created_by' => $user->id, + ]); +}); + +it('fails validation for missing fields', function () { + EinundzwanzigServer::actingAs(User::factory()->create()) + ->tool(CreateCityTool::class, []) + ->assertHasErrors(); +}); + +it('lets the owner update a city', function () { + $user = User::factory()->create(); + $city = City::factory()->create(['created_by' => $user->id]); + + EinundzwanzigServer::actingAs($user) + ->tool(UpdateCityTool::class, ['id' => $city->id, 'name' => 'Nürnberg']) + ->assertOk() + ->assertSee('Nürnberg'); +}); + +it('forbids updating someone elses city', function () { + $owner = User::factory()->create(); + $city = City::factory()->create(['created_by' => $owner->id]); + + EinundzwanzigServer::actingAs(User::factory()->create()) + ->tool(UpdateCityTool::class, ['id' => $city->id, 'name' => 'Hijack']) + ->assertHasErrors(); +}); + +it('returns only own cities in the mine list', function () { + $user = User::factory()->create(); + City::factory()->count(2)->create(['created_by' => $user->id]); + City::factory()->create(['created_by' => User::factory()->create()->id]); + + EinundzwanzigServer::actingAs($user) + ->tool(ListMyCitiesTool::class) + ->assertOk(); +}); + +it('forbids viewing someone elses city in mine show', function () { + $owner = User::factory()->create(); + $city = City::factory()->create(['created_by' => $owner->id]); + + EinundzwanzigServer::actingAs(User::factory()->create()) + ->tool(ShowMyCityTool::class, ['id' => $city->id]) + ->assertHasErrors(); +}); diff --git a/tests/Feature/Mcp/CourseEventMcpToolTest.php b/tests/Feature/Mcp/CourseEventMcpToolTest.php new file mode 100644 index 0000000..45d5b6a --- /dev/null +++ b/tests/Feature/Mcp/CourseEventMcpToolTest.php @@ -0,0 +1,90 @@ +create(); + $venue = Venue::factory()->create(); + + EinundzwanzigServer::actingAs(User::factory()->create(['is_lecturer' => false])) + ->tool(CreateCourseEventTool::class, [ + 'course_id' => $course->id, + 'venue_id' => $venue->id, + 'from' => '2026-07-01 18:00:00', + 'to' => '2026-07-01 21:00:00', + 'link' => 'https://clavastack.com/produkt/specter-shield-lite-workshop', + ]) + ->assertHasErrors(); +}); + +it('lets a lecturer create a course event and stamps created_by', function () { + $user = User::factory()->lecturer()->create(); + $course = Course::factory()->create(); + $venue = Venue::factory()->create(); + + EinundzwanzigServer::actingAs($user) + ->tool(CreateCourseEventTool::class, [ + 'course_id' => $course->id, + 'venue_id' => $venue->id, + 'from' => '2026-07-01 18:00:00', + 'to' => '2026-07-01 21:00:00', + 'link' => 'https://clavastack.com/produkt/specter-shield-lite-workshop', + ]) + ->assertOk(); + + $this->assertDatabaseHas('course_events', [ + 'course_id' => $course->id, + 'venue_id' => $venue->id, + 'created_by' => $user->id, + ]); +}); + +it('fails validation for missing fields', function () { + EinundzwanzigServer::actingAs(User::factory()->lecturer()->create()) + ->tool(CreateCourseEventTool::class, []) + ->assertHasErrors(); +}); + +it('lets the owner update their course event', function () { + $user = User::factory()->lecturer()->create(); + $event = CourseEvent::factory()->create(['created_by' => $user->id]); + + EinundzwanzigServer::actingAs($user) + ->tool(UpdateCourseEventTool::class, [ + 'id' => $event->id, + 'link' => 'https://einundzwanzig.space/courses/updated', + ]) + ->assertOk() + ->assertSee('https://einundzwanzig.space/courses/updated'); +}); + +it('forbids updating a course event owned by someone else', function () { + $owner = User::factory()->lecturer()->create(); + $event = CourseEvent::factory()->create(['created_by' => $owner->id]); + + EinundzwanzigServer::actingAs(User::factory()->lecturer()->create()) + ->tool(UpdateCourseEventTool::class, [ + 'id' => $event->id, + 'link' => 'https://einundzwanzig.space/courses/hijacked', + ]) + ->assertHasErrors(); +}); + +it('returns only own course events in the mine list', function () { + $user = User::factory()->lecturer()->create(); + $other = User::factory()->lecturer()->create(); + + CourseEvent::factory()->count(2)->create(['created_by' => $user->id]); + CourseEvent::factory()->create(['created_by' => $other->id]); + + EinundzwanzigServer::actingAs($user) + ->tool(ListMyCourseEventsTool::class) + ->assertOk(); +}); diff --git a/tests/Feature/Mcp/CourseMcpToolTest.php b/tests/Feature/Mcp/CourseMcpToolTest.php new file mode 100644 index 0000000..7921486 --- /dev/null +++ b/tests/Feature/Mcp/CourseMcpToolTest.php @@ -0,0 +1,62 @@ +lecturer()->create(); + $lecturer = Lecturer::factory()->create(); + + $response = EinundzwanzigServer::actingAs($user)->tool(CreateCourseTool::class, [ + 'name' => 'Bitcoin Grundlagen', + 'lecturer_id' => $lecturer->id, + ]); + + $response->assertOk()->assertSee('Bitcoin Grundlagen'); + + $this->assertDatabaseHas('courses', [ + 'name' => 'Bitcoin Grundlagen', + 'created_by' => $user->id, + ]); +}); + +it('forbids a non-lecturer from creating a course', function () { + $user = User::factory()->create(['is_lecturer' => false]); + $lecturer = Lecturer::factory()->create(); + + EinundzwanzigServer::actingAs($user) + ->tool(CreateCourseTool::class, [ + 'name' => 'Verbotener Kurs', + 'lecturer_id' => $lecturer->id, + ]) + ->assertHasErrors(); +}); + +it('fails validation for missing fields', function () { + EinundzwanzigServer::actingAs(User::factory()->lecturer()->create()) + ->tool(CreateCourseTool::class, []) + ->assertHasErrors(); +}); + +it('lets the owner update a course', function () { + $user = User::factory()->lecturer()->create(); + $course = Course::factory()->create(['created_by' => $user->id]); + + EinundzwanzigServer::actingAs($user) + ->tool(UpdateCourseTool::class, ['id' => $course->id, 'name' => 'Aktualisierter Kurs']) + ->assertOk() + ->assertSee('Aktualisierter Kurs'); +}); + +it('forbids updating someone elses course', function () { + $owner = User::factory()->lecturer()->create(); + $course = Course::factory()->create(['created_by' => $owner->id]); + + EinundzwanzigServer::actingAs(User::factory()->lecturer()->create()) + ->tool(UpdateCourseTool::class, ['id' => $course->id, 'name' => 'Hijack']) + ->assertHasErrors(); +}); diff --git a/tests/Feature/Mcp/EinundzwanzigServerTest.php b/tests/Feature/Mcp/EinundzwanzigServerTest.php new file mode 100644 index 0000000..a6c7a03 --- /dev/null +++ b/tests/Feature/Mcp/EinundzwanzigServerTest.php @@ -0,0 +1,24 @@ +postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/list', + ])->assertUnauthorized(); +}); + +it('registers every domain tool on the server', function () { + $property = (new ReflectionClass(EinundzwanzigServer::class))->getProperty('tools'); + $tools = $property->getDefaultValue(); + + expect($tools)->toHaveCount(30) + ->and($tools)->toContain(CreateMeetupTool::class) + ->and($tools)->toContain(UpdateCourseEventTool::class) + ->and($tools)->toContain(SearchCitiesTool::class); +}); diff --git a/tests/Feature/Mcp/LecturerMcpToolTest.php b/tests/Feature/Mcp/LecturerMcpToolTest.php new file mode 100644 index 0000000..196d6ff --- /dev/null +++ b/tests/Feature/Mcp/LecturerMcpToolTest.php @@ -0,0 +1,68 @@ +create(); + + $response = EinundzwanzigServer::actingAs($user)->tool(CreateLecturerTool::class, [ + 'name' => 'Saifedean Ammous', + ]); + + $response->assertOk()->assertSee('Saifedean Ammous'); + + $this->assertDatabaseHas('lecturers', [ + 'name' => 'Saifedean Ammous', + 'created_by' => $user->id, + ]); +}); + +it('fails validation for missing fields', function () { + EinundzwanzigServer::actingAs(User::factory()->create()) + ->tool(CreateLecturerTool::class, []) + ->assertHasErrors(); +}); + +it('lets the owner update a lecturer', function () { + $user = User::factory()->create(); + $lecturer = Lecturer::factory()->create(['created_by' => $user->id]); + + EinundzwanzigServer::actingAs($user) + ->tool(UpdateLecturerTool::class, ['id' => $lecturer->id, 'name' => 'Knut Svanholm']) + ->assertOk() + ->assertSee('Knut Svanholm'); +}); + +it('forbids updating someone elses lecturer', function () { + $owner = User::factory()->create(); + $lecturer = Lecturer::factory()->create(['created_by' => $owner->id]); + + EinundzwanzigServer::actingAs(User::factory()->create()) + ->tool(UpdateLecturerTool::class, ['id' => $lecturer->id, 'name' => 'Hijack']) + ->assertHasErrors(); +}); + +it('returns only own lecturers in the mine list', function () { + $user = User::factory()->create(); + Lecturer::factory()->count(2)->create(['created_by' => $user->id]); + Lecturer::factory()->create(['created_by' => User::factory()->create()->id]); + + EinundzwanzigServer::actingAs($user) + ->tool(ListMyLecturersTool::class) + ->assertOk(); +}); + +it('forbids viewing someone elses lecturer in mine show', function () { + $owner = User::factory()->create(); + $lecturer = Lecturer::factory()->create(['created_by' => $owner->id]); + + EinundzwanzigServer::actingAs(User::factory()->create()) + ->tool(ShowMyLecturerTool::class, ['id' => $lecturer->id]) + ->assertHasErrors(); +}); diff --git a/tests/Feature/Mcp/MeetupEventMcpToolTest.php b/tests/Feature/Mcp/MeetupEventMcpToolTest.php new file mode 100644 index 0000000..9a96fdf --- /dev/null +++ b/tests/Feature/Mcp/MeetupEventMcpToolTest.php @@ -0,0 +1,72 @@ +create(); + $meetup = Meetup::factory()->create(); + + $response = EinundzwanzigServer::actingAs($user)->tool(CreateMeetupEventTool::class, [ + 'meetup_id' => $meetup->id, + 'start' => '2026-08-01 18:00:00', + 'location' => 'Marktplatz', + ]); + + $response->assertOk()->assertSee('Marktplatz'); + + $this->assertDatabaseHas('meetup_events', [ + 'location' => 'Marktplatz', + 'created_by' => $user->id, + ]); +}); + +it('fails validation for missing fields', function () { + EinundzwanzigServer::actingAs(User::factory()->create()) + ->tool(CreateMeetupEventTool::class, []) + ->assertHasErrors(); +}); + +it('lets the owner update a meetup event', function () { + $user = User::factory()->create(); + $meetupEvent = MeetupEvent::factory()->create(['created_by' => $user->id]); + + EinundzwanzigServer::actingAs($user) + ->tool(UpdateMeetupEventTool::class, ['id' => $meetupEvent->id, 'location' => 'Rathaus']) + ->assertOk() + ->assertSee('Rathaus'); +}); + +it('forbids updating someone elses meetup event', function () { + $owner = User::factory()->create(); + $meetupEvent = MeetupEvent::factory()->create(['created_by' => $owner->id]); + + EinundzwanzigServer::actingAs(User::factory()->create()) + ->tool(UpdateMeetupEventTool::class, ['id' => $meetupEvent->id, 'location' => 'Hijack']) + ->assertHasErrors(); +}); + +it('returns only own meetup events in the mine list', function () { + $user = User::factory()->create(); + MeetupEvent::factory()->count(2)->create(['created_by' => $user->id]); + MeetupEvent::factory()->create(['created_by' => User::factory()->create()->id]); + + EinundzwanzigServer::actingAs($user) + ->tool(ListMyMeetupEventsTool::class) + ->assertOk(); +}); + +it('forbids viewing someone elses meetup event in mine show', function () { + $owner = User::factory()->create(); + $meetupEvent = MeetupEvent::factory()->create(['created_by' => $owner->id]); + + EinundzwanzigServer::actingAs(User::factory()->create()) + ->tool(ShowMyMeetupEventTool::class, ['id' => $meetupEvent->id]) + ->assertHasErrors(); +}); diff --git a/tests/Feature/Mcp/MeetupMcpToolTest.php b/tests/Feature/Mcp/MeetupMcpToolTest.php new file mode 100644 index 0000000..ed852d6 --- /dev/null +++ b/tests/Feature/Mcp/MeetupMcpToolTest.php @@ -0,0 +1,71 @@ +create(); + $city = City::factory()->create(); + + $response = EinundzwanzigServer::actingAs($user)->tool(CreateMeetupTool::class, [ + 'name' => 'Einundzwanzig Ansbach', + 'city_id' => $city->id, + ]); + + $response->assertOk()->assertSee('Einundzwanzig Ansbach'); + + $this->assertDatabaseHas('meetups', [ + 'name' => 'Einundzwanzig Ansbach', + 'created_by' => $user->id, + ]); +}); + +it('fails validation for missing fields', function () { + EinundzwanzigServer::actingAs(User::factory()->create()) + ->tool(CreateMeetupTool::class, []) + ->assertHasErrors(); +}); + +it('lets the owner update a meetup', function () { + $user = User::factory()->create(); + $meetup = Meetup::factory()->create(['created_by' => $user->id]); + + EinundzwanzigServer::actingAs($user) + ->tool(UpdateMeetupTool::class, ['id' => $meetup->id, 'name' => 'Plan B Lugano']) + ->assertOk() + ->assertSee('Plan B Lugano'); +}); + +it('forbids updating someone elses meetup', function () { + $owner = User::factory()->create(); + $meetup = Meetup::factory()->create(['created_by' => $owner->id]); + + EinundzwanzigServer::actingAs(User::factory()->create()) + ->tool(UpdateMeetupTool::class, ['id' => $meetup->id, 'name' => 'Hijack']) + ->assertHasErrors(); +}); + +it('returns only own meetups in the mine list', function () { + $user = User::factory()->create(); + Meetup::factory()->count(2)->create(['created_by' => $user->id]); + Meetup::factory()->create(['created_by' => User::factory()->create()->id]); + + EinundzwanzigServer::actingAs($user) + ->tool(ListMyMeetupsTool::class) + ->assertOk(); +}); + +it('forbids viewing someone elses meetup in mine show', function () { + $owner = User::factory()->create(); + $meetup = Meetup::factory()->create(['created_by' => $owner->id]); + + EinundzwanzigServer::actingAs(User::factory()->create()) + ->tool(ShowMyMeetupTool::class, ['id' => $meetup->id]) + ->assertHasErrors(); +}); diff --git a/tests/Feature/Mcp/OAuthMcpTest.php b/tests/Feature/Mcp/OAuthMcpTest.php new file mode 100644 index 0000000..fbddcd7 --- /dev/null +++ b/tests/Feature/Mcp/OAuthMcpTest.php @@ -0,0 +1,99 @@ +toBe('passport'); +}); + +it('exposes protected-resource discovery metadata', function () { + $this->getJson('/.well-known/oauth-protected-resource') + ->assertOk() + ->assertJsonStructure(['resource', 'authorization_servers', 'scopes_supported']) + ->assertJsonPath('scopes_supported', ['mcp:use']); +}); + +it('exposes authorization-server discovery metadata pointing at passport', function () { + $this->getJson('/.well-known/oauth-authorization-server') + ->assertOk() + ->assertJsonPath('authorization_endpoint', route('passport.authorizations.authorize')) + ->assertJsonPath('token_endpoint', route('passport.token')) + ->assertJsonPath('scopes_supported', ['mcp:use']) + ->assertJsonPath('code_challenge_methods_supported', ['S256']) + ->assertJsonPath('grant_types_supported', ['authorization_code', 'refresh_token']); +}); + +it('returns 401 with an OAuth discovery WWW-Authenticate header for guests', function () { + $response = $this->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/list', + ]); + + $response->assertUnauthorized(); + expect($response->headers->get('WWW-Authenticate')) + ->toContain('resource_metadata=') + ->toContain('oauth-protected-resource'); +}); + +it('lets a permitted client register dynamically', function () { + $this->postJson('/oauth/register', [ + 'client_name' => 'Claude', + 'redirect_uris' => ['https://claude.ai/api/mcp/auth_callback'], + ]) + ->assertOk() + ->assertJsonStructure(['client_id', 'redirect_uris', 'scope']) + ->assertJsonPath('scope', 'mcp:use'); +}); + +it('rejects dynamic registration from a disallowed redirect domain', function () { + $this->postJson('/oauth/register', [ + 'client_name' => 'Evil', + 'redirect_uris' => ['https://evil.example/callback'], + ]) + ->assertStatus(400) + ->assertJsonPath('error', 'invalid_redirect_uri'); +}); + +it('authenticates the mcp endpoint via a passport oauth token', function () { + Passport::actingAs(User::factory()->create(), ['mcp:use']); + + $response = $this->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'ping', + ]); + + $response->assertSuccessful(); + expect($response->headers->get('WWW-Authenticate'))->toBeNull(); +}); + +it('still authenticates the mcp endpoint via a static sanctum token', function () { + $token = User::factory()->create()->createToken('claude-code')->plainTextToken; + + $response = $this->withToken($token)->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'ping', + ]); + + $response->assertSuccessful(); +}); + +it('rejects a passport token that lacks the mcp:use scope', function () { + Passport::actingAs(User::factory()->create(), []); + + $this->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'ping', + ])->assertForbidden(); +}); + +it('rejects an authorize request that uses plain PKCE instead of S256', function () { + $this->actingAs(User::factory()->create()); + + $this->get('/oauth/authorize?response_type=code&client_id=1&redirect_uri=https%3A%2F%2Fclaude.ai%2Fcb&code_challenge=abc123&code_challenge_method=plain') + ->assertStatus(400); +}); diff --git a/tests/Feature/Mcp/SearchMcpToolTest.php b/tests/Feature/Mcp/SearchMcpToolTest.php new file mode 100644 index 0000000..75a78f3 --- /dev/null +++ b/tests/Feature/Mcp/SearchMcpToolTest.php @@ -0,0 +1,73 @@ +create(['name' => 'Ansbach']); + + EinundzwanzigServer::actingAs(User::factory()->create()) + ->tool(SearchCitiesTool::class, []) + ->assertOk() + ->assertSee('Ansbach'); +}); + +it('returns venues', function () { + Venue::factory()->create(['name' => 'Plan B Lugano']); + + EinundzwanzigServer::actingAs(User::factory()->create()) + ->tool(SearchVenuesTool::class, []) + ->assertOk() + ->assertSee('Plan B Lugano'); +}); + +it('returns lecturers', function () { + Lecturer::factory()->create(['name' => 'Saifedean Ammous']); + + EinundzwanzigServer::actingAs(User::factory()->create()) + ->tool(SearchLecturersTool::class, []) + ->assertOk() + ->assertSee('Saifedean Ammous'); +}); + +it('returns courses', function () { + Course::factory()->create(['name' => 'Bitcoin Masterclass']); + + EinundzwanzigServer::actingAs(User::factory()->create()) + ->tool(SearchCoursesTool::class, []) + ->assertOk() + ->assertSee('Bitcoin Masterclass'); +}); + +it('filters courses by user_id', function () { + $user = User::factory()->create(); + Course::factory()->create(['name' => 'Owned Course', 'created_by' => $user->id]); + + EinundzwanzigServer::actingAs($user) + ->tool(SearchCoursesTool::class, ['user_id' => $user->id]) + ->assertOk() + ->assertSee('Owned Course'); +}); + +it('lists countries', function () { + Country::factory()->create(['name' => 'Deutschland', 'code' => 'DE']); + + EinundzwanzigServer::actingAs(User::factory()->create()) + ->tool(ListCountriesTool::class, []) + ->assertOk(); +}); diff --git a/tests/Feature/Mcp/VenueMcpToolTest.php b/tests/Feature/Mcp/VenueMcpToolTest.php new file mode 100644 index 0000000..df2a6a7 --- /dev/null +++ b/tests/Feature/Mcp/VenueMcpToolTest.php @@ -0,0 +1,72 @@ +create(); + $city = City::factory()->create(); + + $response = EinundzwanzigServer::actingAs($user)->tool(CreateVenueTool::class, [ + 'name' => 'Bitcoin Hub', + 'city_id' => $city->id, + 'street' => 'Satoshi Street 21', + ]); + + $response->assertOk()->assertSee('Bitcoin Hub'); + + $this->assertDatabaseHas('venues', [ + 'name' => 'Bitcoin Hub', + 'created_by' => $user->id, + ]); +}); + +it('fails validation for missing fields', function () { + EinundzwanzigServer::actingAs(User::factory()->create()) + ->tool(CreateVenueTool::class, []) + ->assertHasErrors(); +}); + +it('lets the owner update a venue', function () { + $user = User::factory()->create(); + $venue = Venue::factory()->create(['created_by' => $user->id]); + + EinundzwanzigServer::actingAs($user) + ->tool(UpdateVenueTool::class, ['id' => $venue->id, 'name' => 'Orange Hub']) + ->assertOk() + ->assertSee('Orange Hub'); +}); + +it('forbids updating someone elses venue', function () { + $owner = User::factory()->create(); + $venue = Venue::factory()->create(['created_by' => $owner->id]); + + EinundzwanzigServer::actingAs(User::factory()->create()) + ->tool(UpdateVenueTool::class, ['id' => $venue->id, 'name' => 'Hijack']) + ->assertHasErrors(); +}); + +it('returns only own venues in the mine list', function () { + $user = User::factory()->create(); + Venue::factory()->count(2)->create(['created_by' => $user->id]); + Venue::factory()->create(['created_by' => User::factory()->create()->id]); + + EinundzwanzigServer::actingAs($user) + ->tool(ListMyVenuesTool::class) + ->assertOk(); +}); + +it('forbids viewing someone elses venue in mine show', function () { + $owner = User::factory()->create(); + $venue = Venue::factory()->create(['created_by' => $owner->id]); + + EinundzwanzigServer::actingAs(User::factory()->create()) + ->tool(ShowMyVenueTool::class, ['id' => $venue->id]) + ->assertHasErrors(); +});