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:
HolgerHatGarKeineNode
2026-06-08 09:37:00 +02:00
parent 3cad5f5636
commit d0544bfac9
67 changed files with 3948 additions and 83 deletions
+7
View File
@@ -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=
+29
View File
@@ -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);
}
}
+28
View File
@@ -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);
}
}
+106
View File
@@ -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,
];
}
+52
View File
@@ -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.'),
];
}
}
+33
View File
@@ -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());
}
}
+46
View File
@@ -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(),
];
}
}
+54
View File
@@ -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.'),
];
}
}
+47
View File
@@ -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.'),
];
}
}
+54
View File
@@ -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.'),
];
}
}
+60
View File
@@ -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());
}
}
+46
View File
@@ -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(),
];
}
}
+62
View File
@@ -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).'),
];
}
}
+47
View File
@@ -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.'),
];
}
}
+53
View File
@@ -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.'),
];
}
}
+50
View File
@@ -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(),
];
}
}
+33
View File
@@ -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());
}
}
+46
View File
@@ -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(),
];
}
}
+52
View File
@@ -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.'),
];
}
}
+10
View File
@@ -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');
}
+3
View File
@@ -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) {
+2
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -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' => [
+56
View File
@@ -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,
];
+48
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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": ""
}
+180
View File
@@ -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>
+21
View File
@@ -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>
+37
View File
@@ -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']);
+73
View File
@@ -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();
});
+62
View File
@@ -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);
});
+68
View File
@@ -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();
});
+71
View File
@@ -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();
});
+99
View File
@@ -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);
});
+73
View File
@@ -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();
});
+72
View File
@@ -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();
});