mirror of
https://github.com/HolgerHatGarKeineNode/einundzwanzig-app.git
synced 2026-06-10 14:50:29 +00:00
✨ Add OAuth functionality, MCP tools, and feature tests
- 🔒 Added migrations for `oauth_access_tokens`, `oauth_refresh_tokens`, `oauth_auth_codes`, `oauth_clients`, and `oauth_device_codes`. - 🤖 Created MCP tools (Meetups, Cities, Venues, Courses, Lecturers) for managing entities with authentication and validation. - 🛠️ Implemented Passport-backed OAuth API guard configuration and validation endpoints. - ✅ Added comprehensive feature tests for MCP tools and OAuth functionality (access control, validation, and token-based authentication).
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Erzwingt PKCE mit S256 auf dem OAuth-Authorize-Endpunkt.
|
||||
*
|
||||
* Die Discovery-Metadaten bewerben ausschließlich S256, der zugrunde liegende
|
||||
* league/oauth2-server akzeptiert standardmäßig aber auch das schwächere "plain".
|
||||
* Diese Middleware lehnt jede Authorize-Anfrage mit einer anderen Methode als S256 ab,
|
||||
* sodass das tatsächliche Verhalten der beworbenen Metadaten entspricht (OAuth 2.1).
|
||||
*/
|
||||
class EnforcePkceS256
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if ($request->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Erzwingt den OAuth-Scope "mcp:use" auf dem MCP-Endpunkt.
|
||||
*
|
||||
* Greift einheitlich für beide Guards: Sanctum-Tokens (Standard-Ability "*") und
|
||||
* Passport-OAuth-Tokens (Scope "mcp:use") erfüllen die Prüfung über tokenCan(), das
|
||||
* an das jeweilige Token-Modell delegiert. Ein Passport-Token ohne Scope wird abgelehnt.
|
||||
*/
|
||||
class EnsureMcpScope
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Servers;
|
||||
|
||||
use App\Mcp\Tools\City\CreateCityTool;
|
||||
use App\Mcp\Tools\City\ListMyCitiesTool;
|
||||
use App\Mcp\Tools\City\ShowMyCityTool;
|
||||
use App\Mcp\Tools\City\UpdateCityTool;
|
||||
use App\Mcp\Tools\Course\CreateCourseTool;
|
||||
use App\Mcp\Tools\Course\UpdateCourseTool;
|
||||
use App\Mcp\Tools\CourseEvent\CreateCourseEventTool;
|
||||
use App\Mcp\Tools\CourseEvent\ListMyCourseEventsTool;
|
||||
use App\Mcp\Tools\CourseEvent\UpdateCourseEventTool;
|
||||
use App\Mcp\Tools\Lecturer\CreateLecturerTool;
|
||||
use App\Mcp\Tools\Lecturer\ListMyLecturersTool;
|
||||
use App\Mcp\Tools\Lecturer\ShowMyLecturerTool;
|
||||
use App\Mcp\Tools\Lecturer\UpdateLecturerTool;
|
||||
use App\Mcp\Tools\Meetup\CreateMeetupTool;
|
||||
use App\Mcp\Tools\Meetup\ListMyMeetupsTool;
|
||||
use App\Mcp\Tools\Meetup\ShowMyMeetupTool;
|
||||
use App\Mcp\Tools\Meetup\UpdateMeetupTool;
|
||||
use App\Mcp\Tools\MeetupEvent\CreateMeetupEventTool;
|
||||
use App\Mcp\Tools\MeetupEvent\ListMyMeetupEventsTool;
|
||||
use App\Mcp\Tools\MeetupEvent\ShowMyMeetupEventTool;
|
||||
use App\Mcp\Tools\MeetupEvent\UpdateMeetupEventTool;
|
||||
use App\Mcp\Tools\Search\ListCountriesTool;
|
||||
use App\Mcp\Tools\Search\SearchCitiesTool;
|
||||
use App\Mcp\Tools\Search\SearchCoursesTool;
|
||||
use App\Mcp\Tools\Search\SearchLecturersTool;
|
||||
use App\Mcp\Tools\Search\SearchVenuesTool;
|
||||
use App\Mcp\Tools\Venue\CreateVenueTool;
|
||||
use App\Mcp\Tools\Venue\ListMyVenuesTool;
|
||||
use App\Mcp\Tools\Venue\ShowMyVenueTool;
|
||||
use App\Mcp\Tools\Venue\UpdateVenueTool;
|
||||
use Laravel\Mcp\Server;
|
||||
use Laravel\Mcp\Server\Attributes\Instructions;
|
||||
use Laravel\Mcp\Server\Attributes\Name;
|
||||
use Laravel\Mcp\Server\Attributes\Version;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
|
||||
#[Name('Einundzwanzig API')]
|
||||
#[Version('1.0.0')]
|
||||
#[Instructions(<<<'TXT'
|
||||
Dieser Server spiegelt die authentifizierte Einundzwanzig-API. Jeder Aufruf läuft im Kontext
|
||||
des per Sanctum-Token angemeldeten Nutzers; beim Anlegen wird der Ersteller (created_by)
|
||||
automatisch auf diesen Nutzer gesetzt. Schreib- und Eigentums-Operationen (update, my-*) sind
|
||||
nur für den Ersteller oder einen Super-Admin erlaubt.
|
||||
|
||||
Fremdschlüssel (city_id, venue_id, lecturer_id, course_id) zuerst über die search-* Tools
|
||||
auflösen, bevor ein Datensatz angelegt oder aktualisiert wird.
|
||||
TXT)]
|
||||
class EinundzwanzigServer extends Server
|
||||
{
|
||||
/**
|
||||
* The tools registered with this MCP server.
|
||||
*
|
||||
* @var array<int, class-string<Tool>>
|
||||
*/
|
||||
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,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Tools\City;
|
||||
|
||||
use App\Http\Requests\Api\StoreCityRequest;
|
||||
use App\Http\Resources\CityResource;
|
||||
use App\Models\City;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
|
||||
#[Description('Legt eine neue Stadt für den authentifizierten Nutzer an. Der Ersteller (created_by) wird automatisch gesetzt.')]
|
||||
class CreateCityTool extends Tool
|
||||
{
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$user = $request->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<string, Type>
|
||||
*/
|
||||
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.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Tools\City;
|
||||
|
||||
use App\Http\Resources\CityResource;
|
||||
use App\Models\City;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||
|
||||
#[IsReadOnly]
|
||||
#[Description('Listet alle vom authentifizierten Nutzer erstellten Städte, alphabetisch sortiert.')]
|
||||
class ListMyCitiesTool extends Tool
|
||||
{
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$user = $request->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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Tools\City;
|
||||
|
||||
use App\Http\Resources\CityResource;
|
||||
use App\Models\City;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||
|
||||
#[IsReadOnly]
|
||||
#[Description('Zeigt eine einzelne, vom authentifizierten Nutzer erstellte Stadt.')]
|
||||
class ShowMyCityTool extends Tool
|
||||
{
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$city = City::find($request->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<string, Type>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'id' => $schema->integer()->description('ID der Stadt.')->required(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Tools\City;
|
||||
|
||||
use App\Http\Requests\Api\UpdateCityRequest;
|
||||
use App\Http\Resources\CityResource;
|
||||
use App\Models\City;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
|
||||
#[Description('Aktualisiert eine bestehende Stadt. Nur der Ersteller oder ein Super-Admin darf sie ändern.')]
|
||||
class UpdateCityTool extends Tool
|
||||
{
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$city = City::find($request->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<string, Type>
|
||||
*/
|
||||
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.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Tools\Course;
|
||||
|
||||
use App\Models\Course;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
|
||||
#[Description('Legt einen neuen Kurs für den authentifizierten Referenten an. Der Ersteller (created_by) wird automatisch gesetzt.')]
|
||||
class CreateCourseTool extends Tool
|
||||
{
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$user = $request->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<string, Type>
|
||||
*/
|
||||
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.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Tools\Course;
|
||||
|
||||
use App\Models\Course;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
|
||||
#[Description('Aktualisiert einen bestehenden Kurs. Nur der Ersteller oder ein Super-Admin darf ihn ändern.')]
|
||||
class UpdateCourseTool extends Tool
|
||||
{
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$course = Course::find($request->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<string, Type>
|
||||
*/
|
||||
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.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Tools\CourseEvent;
|
||||
|
||||
use App\Models\CourseEvent;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
|
||||
#[Description('Legt ein neues Kurs-Event für den authentifizierten Referenten an. Der Ersteller (created_by) wird automatisch gesetzt.')]
|
||||
class CreateCourseEventTool extends Tool
|
||||
{
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$user = $request->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<string, Type>
|
||||
*/
|
||||
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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Tools\CourseEvent;
|
||||
|
||||
use App\Models\CourseEvent;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||
|
||||
#[IsReadOnly]
|
||||
#[Description('Listet alle vom authentifizierten Nutzer erstellten Kurs-Events (inkl. Kurs und Veranstaltungsort), absteigend nach Startdatum. Optional nach Kurs filterbar.')]
|
||||
class ListMyCourseEventsTool extends Tool
|
||||
{
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$user = $request->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<string, Type>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'course_id' => $schema->integer()->description('Filtert die Kurs-Events auf einen bestimmten Kurs.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Tools\CourseEvent;
|
||||
|
||||
use App\Models\CourseEvent;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
|
||||
#[Description('Aktualisiert ein bestehendes Kurs-Event. Nur der Ersteller oder ein Super-Admin darf es ändern.')]
|
||||
class UpdateCourseEventTool extends Tool
|
||||
{
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$courseEvent = CourseEvent::find($request->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<string, Type>
|
||||
*/
|
||||
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.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Tools\Lecturer;
|
||||
|
||||
use App\Http\Requests\Api\StoreLecturerRequest;
|
||||
use App\Http\Resources\LecturerResource;
|
||||
use App\Models\Lecturer;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
|
||||
#[Description('Legt einen neuen Referenten für den authentifizierten Nutzer an. Der Ersteller (created_by) wird automatisch gesetzt.')]
|
||||
class CreateLecturerTool extends Tool
|
||||
{
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$user = $request->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<string, Type>
|
||||
*/
|
||||
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.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Tools\Lecturer;
|
||||
|
||||
use App\Http\Resources\LecturerResource;
|
||||
use App\Models\Lecturer;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||
|
||||
#[IsReadOnly]
|
||||
#[Description('Listet alle vom authentifizierten Nutzer erstellten Referenten, alphabetisch sortiert.')]
|
||||
class ListMyLecturersTool extends Tool
|
||||
{
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$user = $request->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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Tools\Lecturer;
|
||||
|
||||
use App\Http\Resources\LecturerResource;
|
||||
use App\Models\Lecturer;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||
|
||||
#[IsReadOnly]
|
||||
#[Description('Zeigt einen einzelnen, vom authentifizierten Nutzer erstellten Referenten.')]
|
||||
class ShowMyLecturerTool extends Tool
|
||||
{
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$lecturer = Lecturer::find($request->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<string, Type>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'id' => $schema->integer()->description('ID des Referenten.')->required(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Tools\Lecturer;
|
||||
|
||||
use App\Http\Requests\Api\UpdateLecturerRequest;
|
||||
use App\Http\Resources\LecturerResource;
|
||||
use App\Models\Lecturer;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
|
||||
#[Description('Aktualisiert einen bestehenden Referenten. Nur der Ersteller oder ein Super-Admin darf ihn ändern.')]
|
||||
class UpdateLecturerTool extends Tool
|
||||
{
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$lecturer = Lecturer::find($request->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<string, Type>
|
||||
*/
|
||||
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.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Tools\Meetup;
|
||||
|
||||
use App\Http\Requests\Api\StoreMeetupRequest;
|
||||
use App\Http\Resources\MeetupResource;
|
||||
use App\Models\Meetup;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
|
||||
#[Description('Legt ein neues Meetup für den authentifizierten Nutzer an. Der Ersteller (created_by) wird automatisch gesetzt.')]
|
||||
class CreateMeetupTool extends Tool
|
||||
{
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$user = $request->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<string, Type>
|
||||
*/
|
||||
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.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Tools\Meetup;
|
||||
|
||||
use App\Http\Resources\MeetupResource;
|
||||
use App\Models\Meetup;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||
|
||||
#[IsReadOnly]
|
||||
#[Description('Listet alle vom authentifizierten Nutzer erstellten Meetups, alphabetisch sortiert.')]
|
||||
class ListMyMeetupsTool extends Tool
|
||||
{
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$user = $request->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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Tools\Meetup;
|
||||
|
||||
use App\Http\Resources\MeetupResource;
|
||||
use App\Models\Meetup;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||
|
||||
#[IsReadOnly]
|
||||
#[Description('Zeigt ein einzelnes, vom authentifizierten Nutzer erstelltes Meetup.')]
|
||||
class ShowMyMeetupTool extends Tool
|
||||
{
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$meetup = Meetup::find($request->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<string, Type>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'id' => $schema->integer()->description('ID des Meetups.')->required(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Tools\Meetup;
|
||||
|
||||
use App\Http\Requests\Api\UpdateMeetupRequest;
|
||||
use App\Http\Resources\MeetupResource;
|
||||
use App\Models\Meetup;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
|
||||
#[Description('Aktualisiert ein bestehendes Meetup. Nur der Ersteller oder ein Super-Admin darf es ändern.')]
|
||||
class UpdateMeetupTool extends Tool
|
||||
{
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$meetup = Meetup::find($request->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<string, Type>
|
||||
*/
|
||||
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.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Tools\MeetupEvent;
|
||||
|
||||
use App\Http\Requests\Api\StoreMeetupEventRequest;
|
||||
use App\Http\Resources\MeetupEventResource;
|
||||
use App\Models\MeetupEvent;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
|
||||
#[Description('Legt einen neuen Meetup-Termin für den authentifizierten Nutzer an. Der Ersteller (created_by) wird automatisch gesetzt.')]
|
||||
class CreateMeetupEventTool extends Tool
|
||||
{
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$user = $request->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<string, Type>
|
||||
*/
|
||||
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.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Tools\MeetupEvent;
|
||||
|
||||
use App\Http\Resources\MeetupEventResource;
|
||||
use App\Models\MeetupEvent;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||
|
||||
#[IsReadOnly]
|
||||
#[Description('Listet alle vom authentifizierten Nutzer erstellten Meetup-Termine, nach Startzeitpunkt absteigend sortiert.')]
|
||||
class ListMyMeetupEventsTool extends Tool
|
||||
{
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$user = $request->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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Tools\MeetupEvent;
|
||||
|
||||
use App\Http\Resources\MeetupEventResource;
|
||||
use App\Models\MeetupEvent;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||
|
||||
#[IsReadOnly]
|
||||
#[Description('Zeigt einen einzelnen, vom authentifizierten Nutzer erstellten Meetup-Termin.')]
|
||||
class ShowMyMeetupEventTool extends Tool
|
||||
{
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$meetupEvent = MeetupEvent::find($request->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<string, Type>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'id' => $schema->integer()->description('ID des Meetup-Termins.')->required(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Tools\MeetupEvent;
|
||||
|
||||
use App\Http\Requests\Api\UpdateMeetupEventRequest;
|
||||
use App\Http\Resources\MeetupEventResource;
|
||||
use App\Models\MeetupEvent;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
|
||||
#[Description('Aktualisiert einen bestehenden Meetup-Termin. Nur der Ersteller oder ein Super-Admin darf ihn ändern.')]
|
||||
class UpdateMeetupEventTool extends Tool
|
||||
{
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$meetupEvent = MeetupEvent::find($request->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<string, Type>
|
||||
*/
|
||||
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.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Tools\Search;
|
||||
|
||||
use App\Models\Country;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||
|
||||
#[IsReadOnly]
|
||||
#[Description('Listet Länder (öffentlich) und liefert id, name und code (Ländercode), alphabetisch sortiert, begrenzt auf 10 Einträge. Jedes Land enthält zusätzlich eine flag (SVG-URL). Dient zum Auflösen von country_id.')]
|
||||
class ListCountriesTool extends Tool
|
||||
{
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$search = $request->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<string, Type>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'search' => $schema->string()->description('Suche in Name oder Code (Ländercode).'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Tools\Search;
|
||||
|
||||
use App\Models\City;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||
|
||||
#[IsReadOnly]
|
||||
#[Description('Sucht Städte (öffentlich) und liefert id, name und das zugehörige Land, alphabetisch sortiert, begrenzt auf 10 Einträge. Dient zum Auflösen von city_id vor dem Anlegen von Datensätzen.')]
|
||||
class SearchCitiesTool extends Tool
|
||||
{
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$search = $request->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<string, Type>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'search' => $schema->string()->description('Teilstring-Suche im Namen der Stadt.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Tools\Search;
|
||||
|
||||
use App\Models\Course;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||
|
||||
#[IsReadOnly]
|
||||
#[Description('Sucht Kurse (öffentlich) und liefert id und name, alphabetisch sortiert, begrenzt auf 10 Einträge. Jeder Kurs enthält zusätzlich ein image (Logo-Thumbnail-URL). Optional nach Ersteller (user_id) filterbar. Dient zum Auflösen von course_id.')]
|
||||
class SearchCoursesTool extends Tool
|
||||
{
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$search = $request->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<string, Type>
|
||||
*/
|
||||
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.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Tools\Search;
|
||||
|
||||
use App\Models\Lecturer;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||
|
||||
#[IsReadOnly]
|
||||
#[Description('Sucht Referenten (öffentlich) und liefert id und name, alphabetisch sortiert, begrenzt auf 10 Einträge. Jeder Referent enthält zusätzlich ein image (Avatar-Thumbnail-URL). Dient zum Auflösen von lecturer_id.')]
|
||||
class SearchLecturersTool extends Tool
|
||||
{
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$search = $request->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<string, Type>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'search' => $schema->string()->description('Teilstring-Suche im Namen.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Tools\Search;
|
||||
|
||||
use App\Models\Venue;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||
|
||||
#[IsReadOnly]
|
||||
#[Description('Sucht Veranstaltungsorte (öffentlich) und liefert id, name sowie die zugehörige Stadt/Land, alphabetisch sortiert, begrenzt auf 10 Einträge. Jeder Ort enthält zusätzlich flag (SVG-URL der Landesflagge) und description (Stadt + Straße). Dient zum Auflösen von venue_id.')]
|
||||
class SearchVenuesTool extends Tool
|
||||
{
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$search = $request->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<string, Type>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'search' => $schema->string()->description('Teilstring-Suche im Namen des Veranstaltungsortes.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Tools\Venue;
|
||||
|
||||
use App\Http\Requests\Api\StoreVenueRequest;
|
||||
use App\Http\Resources\VenueResource;
|
||||
use App\Models\Venue;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
|
||||
#[Description('Legt einen neuen Veranstaltungsort (Venue) für den authentifizierten Nutzer an. Der Ersteller (created_by) wird automatisch gesetzt.')]
|
||||
class CreateVenueTool extends Tool
|
||||
{
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$user = $request->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<string, Type>
|
||||
*/
|
||||
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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Tools\Venue;
|
||||
|
||||
use App\Http\Resources\VenueResource;
|
||||
use App\Models\Venue;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||
|
||||
#[IsReadOnly]
|
||||
#[Description('Listet alle vom authentifizierten Nutzer erstellten Veranstaltungsorte, alphabetisch sortiert.')]
|
||||
class ListMyVenuesTool extends Tool
|
||||
{
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$user = $request->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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Tools\Venue;
|
||||
|
||||
use App\Http\Resources\VenueResource;
|
||||
use App\Models\Venue;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||
|
||||
#[IsReadOnly]
|
||||
#[Description('Zeigt einen einzelnen, vom authentifizierten Nutzer erstellten Veranstaltungsort.')]
|
||||
class ShowMyVenueTool extends Tool
|
||||
{
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$venue = Venue::find($request->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<string, Type>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'id' => $schema->integer()->description('ID des Veranstaltungsorts.')->required(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Tools\Venue;
|
||||
|
||||
use App\Http\Requests\Api\UpdateVenueRequest;
|
||||
use App\Http\Resources\VenueResource;
|
||||
use App\Models\Venue;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
|
||||
#[Description('Aktualisiert einen bestehenden Veranstaltungsort (Venue). Nur der Ersteller oder ein Super-Admin darf ihn ändern.')]
|
||||
class UpdateVenueTool extends Tool
|
||||
{
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$venue = Venue::find($request->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<string, Type>
|
||||
*/
|
||||
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.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\DomainMiddleware;
|
||||
use App\Http\Middleware\EnforcePkceS256;
|
||||
use App\Http\Middleware\SetTimezone;
|
||||
use Illuminate\Contracts\Filesystem\FileNotFoundException;
|
||||
use Illuminate\Foundation\Application;
|
||||
@@ -28,6 +29,8 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
DomainMiddleware::class,
|
||||
LangCountrySession::class,
|
||||
SetTimezone::class,
|
||||
// Erzwingt PKCE-S256 auf dem Passport-Authorize-Endpunkt (oauth/authorize).
|
||||
EnforcePkceS256::class,
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+1011
-74
File diff suppressed because it is too large
Load Diff
+8
-1
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
@@ -40,6 +42,11 @@ return [
|
||||
'driver' => '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' => [
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Redirect Domains
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These domains are the domains that OAuth clients are permitted to use
|
||||
| for redirect URIs. Each domain should be specified with its scheme
|
||||
| and host. Domains not in this list will raise validation errors.
|
||||
|
|
||||
| An "*" may be used to allow all domains.
|
||||
|
|
||||
*/
|
||||
|
||||
'redirect_domains' => [
|
||||
// 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,
|
||||
|
||||
];
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Passport Guard
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which authentication guard Passport will use when
|
||||
| authenticating users. This value should correspond with one of your
|
||||
| guards that is already present in your "auth" configuration file.
|
||||
|
|
||||
*/
|
||||
|
||||
'guard' => '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'),
|
||||
|
||||
];
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('oauth_auth_codes', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('oauth_access_tokens', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('oauth_refresh_tokens', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('oauth_clients', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('oauth_device_codes', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
+2
-1
@@ -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": ""
|
||||
}
|
||||
+2
-1
@@ -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": ""
|
||||
}
|
||||
+2
-1
@@ -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": ""
|
||||
}
|
||||
+2
-1
@@ -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": ""
|
||||
}
|
||||
+2
-1
@@ -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": ""
|
||||
}
|
||||
+2
-1
@@ -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": ""
|
||||
}
|
||||
+2
-1
@@ -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": ""
|
||||
}
|
||||
+2
-1
@@ -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": ""
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" @class(['dark' => ($appearance ?? 'system') == 'dark'])>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
{{-- Inline script to detect system dark mode preference and apply it immediately --}}
|
||||
<script>
|
||||
(function() {
|
||||
const appearance = '{{ $appearance ?? "system" }}';
|
||||
|
||||
if (appearance === 'system') {
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
if (prefersDark) {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
html {
|
||||
background-color: oklch(1 0 0);
|
||||
}
|
||||
|
||||
html.dark {
|
||||
background-color: oklch(0.145 0 0);
|
||||
}
|
||||
</style>
|
||||
|
||||
<title>Authorize Application - {{ config('app.name', 'MCP Server') }}</title>
|
||||
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<meta name="apple-mobile-web-app-title" content="Authorize MCP" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600" rel="stylesheet" />
|
||||
|
||||
@vite(['resources/css/app.css'])
|
||||
</head>
|
||||
<body class="font-sans antialiased bg-background text-foreground">
|
||||
<div class="min-h-screen flex items-center justify-center p-4">
|
||||
<div class="w-full max-w-md">
|
||||
<!-- Card Container -->
|
||||
<div class="rounded-lg border bg-card text-card-foreground shadow-sm">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col space-y-1.5 p-6">
|
||||
<div class="flex items-center justify-center mb-4">
|
||||
<!-- Shield Icon -->
|
||||
<svg class="h-12 w-12 text-primary" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.618 5.984A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.031 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h3 class="text-2xl font-semibold leading-none tracking-tight text-center">
|
||||
Authorize {{ $client->name }}
|
||||
</h3>
|
||||
|
||||
<p class="text-sm text-muted-foreground text-center">
|
||||
This application will be able to:<br/>Use available MCP functionality.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-6 pt-0 space-y-4">
|
||||
<!-- User Info -->
|
||||
<div class="rounded-lg border p-4 bg-muted/50">
|
||||
<p class="text-sm text-muted-foreground mb-2">Logged in as:</p>
|
||||
<p class="font-medium">{{ $user->email }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Scopes / Permissions -->
|
||||
@if(count($scopes) > 0)
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm font-medium">Permissions:</p>
|
||||
|
||||
<ul class="space-y-2">
|
||||
@foreach($scopes as $scope)
|
||||
<li class="flex items-start gap-2">
|
||||
<div class="rounded-full bg-primary/10 p-1 mt-0.5">
|
||||
<div class="h-1.5 w-1.5 rounded-full bg-primary"></div>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ $scope->description }}
|
||||
</span>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- Footer With Buttons -->
|
||||
<div class="flex items-center p-6 pt-0 gap-3">
|
||||
<!-- Deny Form -->
|
||||
<form method="POST" action="{{ route('passport.authorizations.deny') }}" class="flex-1">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<input type="hidden" name="state" value="">
|
||||
<input type="hidden" name="client_id" value="{{ $client->id }}">
|
||||
<input type="hidden" name="auth_token" value="{{ $authToken }}">
|
||||
<button type="submit" class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 px-4 py-2 w-full">
|
||||
<svg class="mr-2 h-4 w-4" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
Cancel
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Approve Form -->
|
||||
<form method="POST" action="{{ route('passport.authorizations.approve') }}" class="flex-1" id="authorizeForm">
|
||||
@csrf
|
||||
<input type="hidden" name="state" value="">
|
||||
<input type="hidden" name="client_id" value="{{ $client->id }}">
|
||||
<input type="hidden" name="auth_token" value="{{ $authToken }}">
|
||||
<button type="submit" class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 w-full" id="authorizeButton">
|
||||
<span id="authorizeText">Authorize</span>
|
||||
|
||||
<svg id="loadingSpinner" class="animate-spin -ml-1 mr-3 h-4 w-4 text-white hidden" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.getElementById('authorizeForm');
|
||||
const button = document.getElementById('authorizeButton');
|
||||
const authorizeText = document.getElementById('authorizeText');
|
||||
const loadingSpinner = document.getElementById('loadingSpinner');
|
||||
|
||||
form.addEventListener('submit', function(e) {
|
||||
// Show loading state...
|
||||
button.disabled = true;
|
||||
authorizeText.textContent = 'Authorizing...';
|
||||
loadingSpinner.classList.remove('hidden');
|
||||
|
||||
// After form submission, watch for redirect and close window...
|
||||
setTimeout(function() {
|
||||
const checkRedirect = setInterval(function() {
|
||||
// If URL changed or we have OAuth params, redirect happened...
|
||||
if (!window.location.href.includes('/oauth/authorize') ||
|
||||
window.location.search.includes('code=') ||
|
||||
window.location.search.includes('error=')) {
|
||||
clearInterval(checkRedirect);
|
||||
window.close();
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// Fallback: Close after five seconds...
|
||||
setTimeout(function() {
|
||||
clearInterval(checkRedirect);
|
||||
window.close();
|
||||
}, 5000);
|
||||
}, 200);
|
||||
});
|
||||
|
||||
// Handle cancel button...
|
||||
const cancelForm = document.querySelector('form[method="POST"]:has(input[name="_method"][value="DELETE"])');
|
||||
if (cancelForm) {
|
||||
cancelForm.addEventListener('submit', function(e) {
|
||||
setTimeout(function() {
|
||||
window.close();
|
||||
}, 200);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,21 @@
|
||||
@props(['title' => null])
|
||||
@php
|
||||
$mcpSdk = app('mcp.sdk');
|
||||
$libraryScripts = app()->bound('mcp.library_scripts') ? app('mcp.library_scripts') : '';
|
||||
@endphp
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
@if($title)
|
||||
<title>{{ $title }}</title>
|
||||
@endif
|
||||
<script>{!! $mcpSdk !!}</script>
|
||||
{!! $libraryScripts !!}
|
||||
{{ $head ?? '' }}
|
||||
</head>
|
||||
<body {{ $attributes }}>
|
||||
{{ $slot }}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\EnsureMcpScope;
|
||||
use App\Mcp\Servers\EinundzwanzigServer;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Laravel\Mcp\Facades\Mcp;
|
||||
|
||||
/*
|
||||
* OAuth 2.1 Discovery- und Dynamic-Client-Registration-Routen (RFC 8414 / 9728).
|
||||
* Notwendig, damit Web-Clients wie der Claude.ai-Connector den Server automatisch
|
||||
* entdecken und den Authorization-Code-Flow (über Laravel Passport) starten können.
|
||||
* Gedrosselt, um Client-Registrierungs-Spam zu begrenzen (die Discovery-Endpunkte
|
||||
* werden von Clients ohnehin gecacht).
|
||||
*/
|
||||
Route::middleware('throttle:30,1')->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']);
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
use App\Mcp\Servers\EinundzwanzigServer;
|
||||
use App\Mcp\Tools\City\CreateCityTool;
|
||||
use App\Mcp\Tools\City\ListMyCitiesTool;
|
||||
use App\Mcp\Tools\City\ShowMyCityTool;
|
||||
use App\Mcp\Tools\City\UpdateCityTool;
|
||||
use App\Models\City;
|
||||
use App\Models\Country;
|
||||
use App\Models\User;
|
||||
|
||||
it('lets an authenticated user create a city and stamps created_by', function () {
|
||||
$user = User::factory()->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();
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
use App\Mcp\Servers\EinundzwanzigServer;
|
||||
use App\Mcp\Tools\CourseEvent\CreateCourseEventTool;
|
||||
use App\Mcp\Tools\CourseEvent\ListMyCourseEventsTool;
|
||||
use App\Mcp\Tools\CourseEvent\UpdateCourseEventTool;
|
||||
use App\Models\Course;
|
||||
use App\Models\CourseEvent;
|
||||
use App\Models\User;
|
||||
use App\Models\Venue;
|
||||
|
||||
it('forbids a non-lecturer from creating a course event', function () {
|
||||
$course = Course::factory()->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();
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
use App\Mcp\Servers\EinundzwanzigServer;
|
||||
use App\Mcp\Tools\Course\CreateCourseTool;
|
||||
use App\Mcp\Tools\Course\UpdateCourseTool;
|
||||
use App\Models\Course;
|
||||
use App\Models\Lecturer;
|
||||
use App\Models\User;
|
||||
|
||||
it('lets a lecturer create a course and stamps created_by', function () {
|
||||
$user = User::factory()->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();
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
use App\Mcp\Servers\EinundzwanzigServer;
|
||||
use App\Mcp\Tools\CourseEvent\UpdateCourseEventTool;
|
||||
use App\Mcp\Tools\Meetup\CreateMeetupTool;
|
||||
use App\Mcp\Tools\Search\SearchCitiesTool;
|
||||
|
||||
it('rejects unauthenticated requests to the mcp endpoint', function () {
|
||||
$this->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);
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
use App\Mcp\Servers\EinundzwanzigServer;
|
||||
use App\Mcp\Tools\Lecturer\CreateLecturerTool;
|
||||
use App\Mcp\Tools\Lecturer\ListMyLecturersTool;
|
||||
use App\Mcp\Tools\Lecturer\ShowMyLecturerTool;
|
||||
use App\Mcp\Tools\Lecturer\UpdateLecturerTool;
|
||||
use App\Models\Lecturer;
|
||||
use App\Models\User;
|
||||
|
||||
it('lets an authenticated user create a lecturer and stamps created_by', function () {
|
||||
$user = User::factory()->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();
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
use App\Mcp\Servers\EinundzwanzigServer;
|
||||
use App\Mcp\Tools\MeetupEvent\CreateMeetupEventTool;
|
||||
use App\Mcp\Tools\MeetupEvent\ListMyMeetupEventsTool;
|
||||
use App\Mcp\Tools\MeetupEvent\ShowMyMeetupEventTool;
|
||||
use App\Mcp\Tools\MeetupEvent\UpdateMeetupEventTool;
|
||||
use App\Models\Meetup;
|
||||
use App\Models\MeetupEvent;
|
||||
use App\Models\User;
|
||||
|
||||
it('lets an authenticated user create a meetup event and stamps created_by', function () {
|
||||
$user = User::factory()->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();
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
use App\Mcp\Servers\EinundzwanzigServer;
|
||||
use App\Mcp\Tools\Meetup\CreateMeetupTool;
|
||||
use App\Mcp\Tools\Meetup\ListMyMeetupsTool;
|
||||
use App\Mcp\Tools\Meetup\ShowMyMeetupTool;
|
||||
use App\Mcp\Tools\Meetup\UpdateMeetupTool;
|
||||
use App\Models\City;
|
||||
use App\Models\Meetup;
|
||||
use App\Models\User;
|
||||
|
||||
it('lets an authenticated user create a meetup and stamps created_by', function () {
|
||||
$user = User::factory()->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();
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Laravel\Passport\Passport;
|
||||
|
||||
it('configures the passport-backed api guard', function () {
|
||||
expect(config('auth.guards.api.driver'))->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);
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
use App\Mcp\Servers\EinundzwanzigServer;
|
||||
use App\Mcp\Tools\Search\ListCountriesTool;
|
||||
use App\Mcp\Tools\Search\SearchCitiesTool;
|
||||
use App\Mcp\Tools\Search\SearchCoursesTool;
|
||||
use App\Mcp\Tools\Search\SearchLecturersTool;
|
||||
use App\Mcp\Tools\Search\SearchVenuesTool;
|
||||
use App\Models\City;
|
||||
use App\Models\Country;
|
||||
use App\Models\Course;
|
||||
use App\Models\Lecturer;
|
||||
use App\Models\User;
|
||||
use App\Models\Venue;
|
||||
|
||||
/**
|
||||
* NOTE: The search closures use Postgres `ilike`, which the SQLite test database
|
||||
* does not support. We therefore call the tools without a `search` argument and
|
||||
* rely on the limit(10) branch to return the single seeded record.
|
||||
*/
|
||||
it('returns cities', function () {
|
||||
City::factory()->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();
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
use App\Mcp\Servers\EinundzwanzigServer;
|
||||
use App\Mcp\Tools\Venue\CreateVenueTool;
|
||||
use App\Mcp\Tools\Venue\ListMyVenuesTool;
|
||||
use App\Mcp\Tools\Venue\ShowMyVenueTool;
|
||||
use App\Mcp\Tools\Venue\UpdateVenueTool;
|
||||
use App\Models\City;
|
||||
use App\Models\User;
|
||||
use App\Models\Venue;
|
||||
|
||||
it('lets an authenticated user create a venue and stamps created_by', function () {
|
||||
$user = User::factory()->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();
|
||||
});
|
||||
Reference in New Issue
Block a user