From f97915bf4db392d1963d40bfe8b7defd53d44ea5 Mon Sep 17 00:00:00 2001 From: Kjell Kind Date: Thu, 15 Oct 2026 20:47:21 +0200 Subject: [PATCH 1/3] feat: MCP server with Passport OAuth Adds a remote MCP server at /mcp, authenticated with OAuth through Passport (dynamic client registration, authorization code with PKCE). Tools: read the log, show a commit, search users, commit or reply, star a commit, follow a user. They call the existing actions and queries. Passport's user_id columns are UUIDs to match the users table, and the device code grant is disabled. Co-Authored-By: Claude Sonnet 5.5 --- app/Mcp/CommitResolver.php | 36 + app/Mcp/Presenter.php | 65 ++ app/Mcp/Servers/DevNullServer.php | 39 + app/Mcp/Tools/CreateCommitStarTool.php | 53 ++ app/Mcp/Tools/CreateCommitTool.php | 66 ++ app/Mcp/Tools/CreateUserFollowTool.php | 53 ++ app/Mcp/Tools/ListUsersTool.php | 44 + app/Mcp/Tools/ShowCommitTool.php | 69 ++ app/Mcp/Tools/ShowLogTool.php | 63 ++ app/Models/User.php | 6 +- app/Providers/AppServiceProvider.php | 21 + composer.json | 2 + composer.lock | 792 ++++++++++++++++-- config/auth.php | 5 + ...5_183803_create_oauth_auth_codes_table.php | 41 + ...83804_create_oauth_access_tokens_table.php | 43 + ...3805_create_oauth_refresh_tokens_table.php | 39 + ...0_15_183806_create_oauth_clients_table.php | 44 + ...183807_create_oauth_device_codes_table.php | 44 + resources/js/pages/oauth/authorize.tsx | 85 ++ routes/ai.php | 11 + tests/Feature/Mcp/DevNullServerTest.php | 202 +++++ tests/Feature/Mcp/McpAuthenticationTest.php | 107 +++ 23 files changed, 1840 insertions(+), 90 deletions(-) create mode 100644 app/Mcp/CommitResolver.php create mode 100644 app/Mcp/Presenter.php create mode 100644 app/Mcp/Servers/DevNullServer.php create mode 100644 app/Mcp/Tools/CreateCommitStarTool.php create mode 100644 app/Mcp/Tools/CreateCommitTool.php create mode 100644 app/Mcp/Tools/CreateUserFollowTool.php create mode 100644 app/Mcp/Tools/ListUsersTool.php create mode 100644 app/Mcp/Tools/ShowCommitTool.php create mode 100644 app/Mcp/Tools/ShowLogTool.php create mode 100644 database/migrations/2026_10_15_183803_create_oauth_auth_codes_table.php create mode 100644 database/migrations/2026_10_15_183804_create_oauth_access_tokens_table.php create mode 100644 database/migrations/2026_10_15_183805_create_oauth_refresh_tokens_table.php create mode 100644 database/migrations/2026_10_15_183806_create_oauth_clients_table.php create mode 100644 database/migrations/2026_10_15_183807_create_oauth_device_codes_table.php create mode 100644 resources/js/pages/oauth/authorize.tsx create mode 100644 routes/ai.php create mode 100644 tests/Feature/Mcp/DevNullServerTest.php create mode 100644 tests/Feature/Mcp/McpAuthenticationTest.php diff --git a/app/Mcp/CommitResolver.php b/app/Mcp/CommitResolver.php new file mode 100644 index 000000000..8d712f56f --- /dev/null +++ b/app/Mcp/CommitResolver.php @@ -0,0 +1,36 @@ +query->get($user, mb_trim($reference)); + + if ($commits === null) { + return Response::error('That is not a commit hash, ID, or link.'); + } + + if ($commits->count() > 1) { + return Response::error('That hash matches more than one commit: use the full ID instead.'); + } + + return $commits->first() ?? Response::error('No commit found for that reference.'); + } +} diff --git a/app/Mcp/Presenter.php b/app/Mcp/Presenter.php new file mode 100644 index 000000000..993322bae --- /dev/null +++ b/app/Mcp/Presenter.php @@ -0,0 +1,65 @@ + + */ + public function commit(Commit $commit): array + { + if ($commit->trashed()) { + return [ + 'id' => $commit->id, + 'hash' => $this->hash($commit), + 'deleted' => true, + ]; + } + + return [ + 'id' => $commit->id, + 'hash' => $this->hash($commit), + 'url' => route('commit.show', [$commit->user, $commit]), + 'author' => $commit->user->username, + 'body' => $commit->body, + 'status' => $commit->status->value, + 'parent_id' => $commit->parent_id, + 'created_at' => $commit->created_at->toIso8601String(), + 'stars_count' => $this->loaded($commit, 'stars_count'), + 'replies_count' => $this->loaded($commit, 'replies_count'), + 'starred' => $this->loaded($commit, 'starred') === null ? null : (bool) $this->loaded($commit, 'starred'), + ]; + } + + /** + * @return array + */ + public function user(User $user): array + { + return [ + 'username' => $user->username, + 'name' => $user->name, + 'bio' => $user->bio, + 'url' => route('user.show', $user), + ]; + } + + /** + * Counts and flags only exist on commits that were queried with them. + */ + private function loaded(Commit $commit, string $attribute): mixed + { + return $commit->getAttributes()[$attribute] ?? null; + } + + private function hash(Commit $commit): string + { + return mb_substr($commit->id, -7); + } +} diff --git a/app/Mcp/Servers/DevNullServer.php b/app/Mcp/Servers/DevNullServer.php new file mode 100644 index 000000000..1f0f4d709 --- /dev/null +++ b/app/Mcp/Servers/DevNullServer.php @@ -0,0 +1,39 @@ +user(); + assert($user instanceof User); + + $validated = $request->validate([ + 'commit' => ['required', 'string', 'max:300'], + ]); + + $commit = $resolver->resolve($user, $validated['commit']); + + if (! $commit instanceof Commit) { + return $commit; + } + + $action->handle($user, $commit); + + return Response::text("Starred commit {$commit->id}."); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'commit' => $schema->string() + ->description('The commit hash (last 7 characters of its ID), its full ID, or its link.') + ->required(), + ]; + } +} diff --git a/app/Mcp/Tools/CreateCommitTool.php b/app/Mcp/Tools/CreateCommitTool.php new file mode 100644 index 000000000..9facee6f7 --- /dev/null +++ b/app/Mcp/Tools/CreateCommitTool.php @@ -0,0 +1,66 @@ +user(); + assert($user instanceof User); + + $validated = $request->validate([ + 'body' => ['required', 'string', 'min:3', 'max:280'], + 'parent_id' => ['nullable', 'uuid', new VisibleCommit($user)], + ]); + + $key = "mcp-commits:{$user->id}"; + + if (RateLimiter::tooManyAttempts($key, self::MAX_ATTEMPTS)) { + return Response::error('Slow down: 10 commits a minute. Even CI takes breaks.'); + } + + RateLimiter::hit($key); + + $parent = isset($validated['parent_id']) + ? Commit::query()->visibleTo($user)->find($validated['parent_id']) + : null; + + $commit = $action->handle($user, $validated['body'], $parent); + + return Response::structured([ + 'commit' => $presenter->commit($commit->load('user')), + 'pending_review' => $commit->status === CommitStatus::Pending, + ]); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'body' => $schema->string()->description('What to commit: 3 to 280 characters.')->required(), + 'parent_id' => $schema->string()->description('The full ID of the commit to reply to. Leave out for a new commit.'), + ]; + } +} diff --git a/app/Mcp/Tools/CreateUserFollowTool.php b/app/Mcp/Tools/CreateUserFollowTool.php new file mode 100644 index 000000000..61eb7c869 --- /dev/null +++ b/app/Mcp/Tools/CreateUserFollowTool.php @@ -0,0 +1,53 @@ +user(); + assert($follower instanceof User); + + $validated = $request->validate([ + 'username' => ['required', 'string', 'max:100'], + ]); + + $user = User::query()->where('username', mb_ltrim($validated['username'], '@'))->first(); + + if (! $user instanceof User) { + return Response::error('No user found with that username.'); + } + + if ($follower->is($user)) { + return Response::error('You cannot follow yourself.'); + } + + $action->handle($follower, $user); + + return Response::text("Now following @{$user->username}."); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'username' => $schema->string()->description('The username to follow. A leading @ is ignored.')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/ListUsersTool.php b/app/Mcp/Tools/ListUsersTool.php new file mode 100644 index 000000000..970e56df9 --- /dev/null +++ b/app/Mcp/Tools/ListUsersTool.php @@ -0,0 +1,44 @@ +validate([ + 'query' => ['required', 'string', 'max:100'], + ]); + + return Response::structured([ + 'users' => $query->get($validated['query']) + ->map(fn (User $user): array => $presenter->user($user)) + ->all(), + ]); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'query' => $schema->string()->description('Part of a username or name. A leading @ is ignored.')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/ShowCommitTool.php b/app/Mcp/Tools/ShowCommitTool.php new file mode 100644 index 000000000..2b728f677 --- /dev/null +++ b/app/Mcp/Tools/ShowCommitTool.php @@ -0,0 +1,69 @@ +user(); + assert($user instanceof User); + + $validated = $request->validate([ + 'commit' => ['required', 'string', 'max:300'], + ]); + + $commit = $resolver->resolve($user, $validated['commit']); + + if ($commit instanceof Response) { + return $commit; + } + + $commit = Commit::query() + ->withVisibleReplyCount($user) + ->with('user') + ->withStarsFor($user) + ->findOrFail($commit->id); + + return Response::structured([ + 'commit' => $presenter->commit($commit), + 'ancestors' => $ancestors->get($commit, $user) + ->map(fn (Commit $ancestor): array => $presenter->commit($ancestor)) + ->values() + ->all(), + 'replies' => $replies->paginate($commit, $user)->getCollection() + ->map(fn (Commit $reply): array => $presenter->commit($reply)) + ->all(), + ]); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'commit' => $schema->string() + ->description('The commit hash (last 7 characters of its ID), its full ID, or its link.') + ->required(), + ]; + } +} diff --git a/app/Mcp/Tools/ShowLogTool.php b/app/Mcp/Tools/ShowLogTool.php new file mode 100644 index 000000000..2d675652c --- /dev/null +++ b/app/Mcp/Tools/ShowLogTool.php @@ -0,0 +1,63 @@ +user(); + assert($user instanceof User); + + $validated = $request->validate([ + 'log' => ['sometimes', Rule::enum(Log::class)], + 'cursor' => ['nullable', 'string'], + ]); + + $log = Log::from($validated['log'] ?? Log::Following->value); + + $commits = $log->query() + ->builder($user) + ->cursorPaginate(self::PER_PAGE, cursor: $validated['cursor'] ?? null); + + return Response::structured([ + 'log' => $log->value, + 'commits' => $commits->getCollection() + ->map(fn (Commit $commit): array => $presenter->commit($commit)) + ->all(), + 'next_cursor' => $commits->nextCursor()?->encode(), + ]); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'log' => $schema->string() + ->enum(array_map(fn (Log $log): string => $log->value, Log::cases())) + ->description('Whose commits to read. Defaults to following.'), + 'cursor' => $schema->string()->description('The next_cursor of the previous page.'), + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 6c56f5f55..37dea5550 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -13,6 +13,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; +use Laravel\Passport\Contracts\OAuthenticatable; +use Laravel\Passport\HasApiTokens; /** * @property-read string $id @@ -47,8 +49,10 @@ 'preferred_lane', 'pinned_commit_id', ])] -final class User extends Authenticatable +final class User extends Authenticatable implements OAuthenticatable { + use HasApiTokens; + /** @use HasFactory */ use HasFactory; diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index ad1b74fa9..7e3d39312 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -15,18 +15,37 @@ use Illuminate\Support\ServiceProvider; use Inertia\ExceptionResponse; use Inertia\Inertia; +use Inertia\Response as InertiaResponse; +use Laravel\Passport\Passport; +use Laravel\Passport\Scope; final class AppServiceProvider extends ServiceProvider { public function register(): void { $this->app->bind(DnsResolver::class, SystemDnsResolver::class); + + Passport::$deviceCodeGrantEnabled = false; } public function boot(): void { $this->bootRateLimitingDefaults(); $this->bootExceptionHandling(); + $this->bootPassport(); + } + + private function bootPassport(): void + { + Passport::authorizationView(fn (array $parameters): InertiaResponse => Inertia::render('oauth/authorize', [ + 'client' => $parameters['client']->name, + 'scopes' => array_map(fn (Scope $scope): string => $scope->description, $parameters['scopes']), + 'authToken' => $parameters['authToken'], + 'state' => $parameters['request']->input('state'), + 'clientId' => $parameters['client']->getKey(), + 'csrfToken' => csrf_token(), + 'url' => route('passport.authorizations.approve'), + ])); } private function bootExceptionHandling(): void @@ -62,6 +81,8 @@ private function bootExceptionHandling(): void private function bootRateLimitingDefaults(): void { + RateLimiter::for('mcp', fn (Request $request): Limit => Limit::perMinute(60)->by($request->user()?->id ?? $request->ip())); + RateLimiter::for('commits', function (Request $request): Limit { $user = $request->user(); assert($user instanceof User); diff --git a/composer.json b/composer.json index b54e2ae9b..c3fcd1cf6 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,9 @@ "inertiajs/inertia-laravel": "^3.5.1", "intervention/image": "^4.3.5", "laravel/framework": "^13.36.0", + "laravel/mcp": "^1.0", "laravel/octane": "^2.22", + "laravel/passport": "^13.10", "laravel/socialite": "^5.31.0", "laravel/wayfinder": "^0.1.21", "league/flysystem-aws-s3-v3": "^3.35.3", diff --git a/composer.lock b/composer.lock index 5a008f7e3..c6aa3edc1 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e2ebabc8386dd88ce36e03cb2caa415b", + "content-hash": "a0eb5ae949029522133d38db065ee421", "packages": [ { "name": "aws/aws-crt-php", @@ -291,6 +291,73 @@ ], "time": "2026-09-06T14:11:38+00:00" }, + { + "name": "defuse/php-encryption", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/defuse/php-encryption.git", + "reference": "f53396c2d34225064647a05ca76c1da9d99e5828" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/defuse/php-encryption/zipball/f53396c2d34225064647a05ca76c1da9d99e5828", + "reference": "f53396c2d34225064647a05ca76c1da9d99e5828", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "paragonie/random_compat": ">= 2", + "php": ">=5.6.0" + }, + "require-dev": { + "phpunit/phpunit": "^5|^6|^7|^8|^9|^10", + "yoast/phpunit-polyfills": "^2.0.0" + }, + "bin": [ + "bin/generate-defuse-key" + ], + "type": "library", + "autoload": { + "psr-4": { + "Defuse\\Crypto\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Hornby", + "email": "taylor@defuse.ca", + "homepage": "https://defuse.ca/" + }, + { + "name": "Scott Arciszewski", + "email": "info@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "Secure PHP Encryption Library", + "keywords": [ + "aes", + "authenticated encryption", + "cipher", + "crypto", + "cryptography", + "encrypt", + "encryption", + "openssl", + "security", + "symmetric key cryptography" + ], + "support": { + "issues": "https://github.com/defuse/php-encryption/issues", + "source": "https://github.com/defuse/php-encryption/tree/v2.4.0" + }, + "time": "2023-06-19T06:10:36+00:00" + }, { "name": "dflydev/dot-access-data", "version": "v3.0.3", @@ -1818,6 +1885,81 @@ }, "time": "2026-10-13T13:21:00+00:00" }, + { + "name": "laravel/mcp", + "version": "v1.0.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/mcp.git", + "reference": "26c7803551028653beab49af0e672ae38607b772" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/mcp/zipball/26c7803551028653beab49af0e672ae38607b772", + "reference": "26c7803551028653beab49af0e672ae38607b772", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "illuminate/console": "^11.45.3|^12.41.1|^13.0", + "illuminate/container": "^11.45.3|^12.41.1|^13.0", + "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", + "illuminate/http": "^11.45.3|^12.41.1|^13.0", + "illuminate/json-schema": "^12.41.1|^13.0", + "illuminate/routing": "^11.45.3|^12.41.1|^13.0", + "illuminate/support": "^11.45.3|^12.41.1|^13.0", + "illuminate/validation": "^11.45.3|^12.41.1|^13.0", + "php": "^8.2", + "symfony/process": "^7.4.5|^8.0.5" + }, + "require-dev": { + "jasonmccreary/double": "^1.0", + "laravel/pint": "^1.20", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "pestphp/pest": "^3.8.5|^4.3.2", + "phpstan/phpstan": "^2.1.27", + "rector/rector": "^2.2.4" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Mcp": "Laravel\\Mcp\\Facades\\Mcp" + }, + "providers": [ + "Laravel\\Mcp\\Server\\McpServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Mcp\\": "src/", + "Laravel\\Mcp\\Server\\": "src/Server/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Rapidly build MCP servers for your Laravel applications.", + "homepage": "https://github.com/laravel/mcp", + "keywords": [ + "laravel", + "mcp" + ], + "support": { + "issues": "https://github.com/laravel/mcp/issues", + "source": "https://github.com/laravel/mcp" + }, + "time": "2026-10-11T20:52:46+00:00" + }, { "name": "laravel/octane", "version": "v2.22.0", @@ -1907,6 +2049,82 @@ }, "time": "2026-10-14T15:54:28+00:00" }, + { + "name": "laravel/passport", + "version": "v13.10.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/passport.git", + "reference": "f5f5e9f64559dd52339daa0d6102cb1c30e4a0d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/passport/zipball/f5f5e9f64559dd52339daa0d6102cb1c30e4a0d7", + "reference": "f5f5e9f64559dd52339daa0d6102cb1c30e4a0d7", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-openssl": "*", + "firebase/php-jwt": "^6.4|^7.0", + "illuminate/auth": "^11.35|^12.0|^13.0", + "illuminate/console": "^11.35|^12.0|^13.0", + "illuminate/container": "^11.35|^12.0|^13.0", + "illuminate/contracts": "^11.35|^12.0|^13.0", + "illuminate/cookie": "^11.35|^12.0|^13.0", + "illuminate/database": "^11.35|^12.0|^13.0", + "illuminate/encryption": "^11.35|^12.0|^13.0", + "illuminate/http": "^11.35|^12.0|^13.0", + "illuminate/support": "^11.35|^12.0|^13.0", + "league/oauth2-server": "^9.2", + "php": "^8.2", + "php-http/discovery": "^1.20", + "phpseclib/phpseclib": "^4.0", + "psr/http-factory-implementation": "*", + "symfony/console": "^7.1|^8.0", + "symfony/psr-http-message-bridge": "^7.1|^8.0" + }, + "require-dev": { + "jasonmccreary/double": "^1.0", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "phpstan/phpstan": "^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Passport\\PassportServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Passport\\": "src/", + "Laravel\\Passport\\Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Passport provides OAuth2 server support to Laravel.", + "keywords": [ + "laravel", + "oauth", + "passport" + ], + "support": { + "issues": "https://github.com/laravel/passport/issues", + "source": "https://github.com/laravel/passport" + }, + "time": "2026-10-08T20:13:55+00:00" + }, { "name": "laravel/prompts", "version": "v0.3.26", @@ -2164,6 +2382,79 @@ }, "time": "2026-08-04T21:55:43+00:00" }, + { + "name": "lcobucci/jwt", + "version": "5.6.1", + "source": { + "type": "git", + "url": "https://github.com/lcobucci/jwt.git", + "reference": "482760068e5a55a9045feddb3e2fc3f878cbd577" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lcobucci/jwt/zipball/482760068e5a55a9045feddb3e2fc3f878cbd577", + "reference": "482760068e5a55a9045feddb3e2fc3f878cbd577", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "ext-sodium": "*", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/clock": "^1.0" + }, + "require-dev": { + "infection/infection": "^0.29", + "lcobucci/clock": "^3.2", + "lcobucci/coding-standard": "^11.0", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.2", + "phpstan/phpstan": "^1.10.7", + "phpstan/phpstan-deprecation-rules": "^1.1.3", + "phpstan/phpstan-phpunit": "^1.3.10", + "phpstan/phpstan-strict-rules": "^1.5.0", + "phpunit/phpunit": "^11.1" + }, + "suggest": { + "lcobucci/clock": ">= 3.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Lcobucci\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Luís Cobucci", + "email": "lcobucci@gmail.com", + "role": "Developer" + } + ], + "description": "A simple library to work with JSON Web Token and JSON Web Signature", + "keywords": [ + "JWS", + "jwt" + ], + "support": { + "issues": "https://github.com/lcobucci/jwt/issues", + "source": "https://github.com/lcobucci/jwt/tree/5.6.1" + }, + "funding": [ + { + "url": "https://github.com/lcobucci", + "type": "github" + }, + { + "url": "https://www.patreon.com/lcobucci", + "type": "patreon" + } + ], + "time": "2026-10-10T09:42:14+00:00" + }, { "name": "league/commonmark", "version": "2.10.3", @@ -2353,6 +2644,65 @@ ], "time": "2022-12-11T20:36:23+00:00" }, + { + "name": "league/event", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/event.git", + "reference": "ec38ff7ea10cad7d99a79ac937fbcffb9334c210" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/event/zipball/ec38ff7ea10cad7d99a79ac937fbcffb9334c210", + "reference": "ec38ff7ea10cad7d99a79ac937fbcffb9334c210", + "shasum": "" + }, + "require": { + "php": ">=7.2.0", + "psr/event-dispatcher": "^1.0" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.16", + "phpstan/phpstan": "^0.12.45", + "phpunit/phpunit": "^8.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Event\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frenky.net" + } + ], + "description": "Event package", + "keywords": [ + "emitter", + "event", + "listener" + ], + "support": { + "issues": "https://github.com/thephpleague/event/issues", + "source": "https://github.com/thephpleague/event/tree/3.0.3" + }, + "time": "2024-09-04T16:06:53+00:00" + }, { "name": "league/flysystem", "version": "3.36.0", @@ -2651,26 +3001,123 @@ "role": "Developer" } ], - "description": "OAuth 1.0 Client Library", + "description": "OAuth 1.0 Client Library", + "keywords": [ + "Authentication", + "SSO", + "authorization", + "bitbucket", + "identity", + "idp", + "oauth", + "oauth1", + "single sign on", + "trello", + "tumblr", + "twitter" + ], + "support": { + "issues": "https://github.com/thephpleague/oauth1-client/issues", + "source": "https://github.com/thephpleague/oauth1-client/tree/v1.12.0" + }, + "time": "2026-09-19T05:12:51+00:00" + }, + { + "name": "league/oauth2-server", + "version": "9.4.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/oauth2-server.git", + "reference": "9d2f6fc0a0b5aa1bb02506971d3a4ecff2c6526c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/oauth2-server/zipball/9d2f6fc0a0b5aa1bb02506971d3a4ecff2c6526c", + "reference": "9d2f6fc0a0b5aa1bb02506971d3a4ecff2c6526c", + "shasum": "" + }, + "require": { + "defuse/php-encryption": "^2.4", + "ext-json": "*", + "ext-openssl": "*", + "lcobucci/jwt": "^5.6", + "league/event": "^3.0", + "league/uri": "^7.8", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/clock": "^1.0", + "psr/http-message": "^2.0", + "psr/http-server-middleware": "^1.0" + }, + "replace": { + "league/oauth2server": "*", + "lncd/oauth2": "*" + }, + "require-dev": { + "laminas/laminas-diactoros": "^3.8", + "paragonie/random_compat": "^9.99.100", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.38", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0.12", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^11.5.50", + "roave/security-advisories": "dev-master", + "slevomat/coding-standard": "^8.27.1", + "squizlabs/php_codesniffer": "^4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\OAuth2\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alex Bilbie", + "email": "hello@alexbilbie.com", + "homepage": "http://www.alexbilbie.com", + "role": "Developer" + }, + { + "name": "Andy Millington", + "email": "andrew@noexceptions.io", + "homepage": "https://www.noexceptions.io", + "role": "Developer" + } + ], + "description": "A lightweight and powerful OAuth 2.0 authorization and resource server library with support for all the core specification grants. This library will allow you to secure your API with OAuth and allow your applications users to approve apps that want to access their data from your API.", + "homepage": "https://oauth2.thephpleague.com/", "keywords": [ "Authentication", - "SSO", + "api", + "auth", + "authorisation", "authorization", - "bitbucket", - "identity", - "idp", "oauth", - "oauth1", - "single sign on", - "trello", - "tumblr", - "twitter" + "oauth 2", + "oauth 2.0", + "oauth2", + "protect", + "resource", + "secure", + "server" ], "support": { - "issues": "https://github.com/thephpleague/oauth1-client/issues", - "source": "https://github.com/thephpleague/oauth1-client/tree/v1.12.0" + "issues": "https://github.com/thephpleague/oauth2-server/issues", + "source": "https://github.com/thephpleague/oauth2-server/tree/9.4.1" }, - "time": "2026-09-19T05:12:51+00:00" + "funding": [ + { + "url": "https://github.com/sephster", + "type": "github" + } + ], + "time": "2026-06-25T15:24:07+00:00" }, { "name": "league/uri", @@ -3522,6 +3969,135 @@ }, "time": "2025-09-24T15:06:41+00:00" }, + { + "name": "paragonie/random_compat", + "version": "v9.99.100", + "source": { + "type": "git", + "url": "https://github.com/paragonie/random_compat.git", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a", + "shasum": "" + }, + "require": { + "php": ">= 7" + }, + "require-dev": { + "phpunit/phpunit": "4.*|5.*", + "vimeo/psalm": "^1" + }, + "suggest": { + "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", + "keywords": [ + "csprng", + "polyfill", + "pseudorandom", + "random" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/random_compat/issues", + "source": "https://github.com/paragonie/random_compat" + }, + "time": "2020-10-15T08:29:30+00:00" + }, + { + "name": "php-http/discovery", + "version": "1.20.0", + "source": { + "type": "git", + "url": "https://github.com/php-http/discovery.git", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0|^2.0", + "php": "^7.1 || ^8.0" + }, + "conflict": { + "nyholm/psr7": "<1.0", + "zendframework/zend-diactoros": "*" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "*", + "psr/http-factory-implementation": "*", + "psr/http-message-implementation": "*" + }, + "require-dev": { + "composer/composer": "^1.0.2|^2.0", + "graham-campbell/phpspec-skip-example-extension": "^5.0", + "php-http/httplug": "^1.0 || ^2.0", + "php-http/message-factory": "^1.0", + "phpspec/phpspec": "^5.1 || ^6.1 || ^7.3", + "sebastian/comparator": "^3.0.5 || ^4.0.8", + "symfony/phpunit-bridge": "^6.4.4 || ^7.0.1" + }, + "type": "composer-plugin", + "extra": { + "class": "Http\\Discovery\\Composer\\Plugin", + "plugin-optional": true + }, + "autoload": { + "psr-4": { + "Http\\Discovery\\": "src/" + }, + "exclude-from-classmap": [ + "src/Composer/Plugin.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations", + "homepage": "http://php-http.org", + "keywords": [ + "adapter", + "client", + "discovery", + "factory", + "http", + "message", + "psr17", + "psr7" + ], + "support": { + "issues": "https://github.com/php-http/discovery/issues", + "source": "https://github.com/php-http/discovery/tree/1.20.0" + }, + "time": "2024-10-02T11:20:13+00:00" + }, { "name": "phpoption/phpoption", "version": "1.10.0", @@ -4075,6 +4651,119 @@ }, "time": "2023-04-04T09:54:51+00:00" }, + { + "name": "psr/http-server-handler", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-handler.git", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-handler/zipball/84c4fb66179be4caaf8e97bd239203245302e7d4", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side request handler", + "keywords": [ + "handler", + "http", + "http-interop", + "psr", + "psr-15", + "psr-7", + "request", + "response", + "server" + ], + "support": { + "source": "https://github.com/php-fig/http-server-handler/tree/1.0.2" + }, + "time": "2023-04-10T20:06:20+00:00" + }, + { + "name": "psr/http-server-middleware", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-middleware.git", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-middleware/zipball/c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0", + "psr/http-server-handler": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side middleware", + "keywords": [ + "http", + "http-interop", + "middleware", + "psr", + "psr-15", + "psr-7", + "request", + "response" + ], + "support": { + "issues": "https://github.com/php-fig/http-server-middleware/issues", + "source": "https://github.com/php-fig/http-server-middleware/tree/1.0.2" + }, + "time": "2023-04-11T06:14:47+00:00" + }, { "name": "psr/log", "version": "3.0.2", @@ -9551,81 +10240,6 @@ }, "time": "2026-10-07T19:07:19+00:00" }, - { - "name": "laravel/mcp", - "version": "v1.0.2", - "source": { - "type": "git", - "url": "https://github.com/laravel/mcp.git", - "reference": "26c7803551028653beab49af0e672ae38607b772" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/laravel/mcp/zipball/26c7803551028653beab49af0e672ae38607b772", - "reference": "26c7803551028653beab49af0e672ae38607b772", - "shasum": "" - }, - "require": { - "ext-json": "*", - "ext-mbstring": "*", - "illuminate/console": "^11.45.3|^12.41.1|^13.0", - "illuminate/container": "^11.45.3|^12.41.1|^13.0", - "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", - "illuminate/http": "^11.45.3|^12.41.1|^13.0", - "illuminate/json-schema": "^12.41.1|^13.0", - "illuminate/routing": "^11.45.3|^12.41.1|^13.0", - "illuminate/support": "^11.45.3|^12.41.1|^13.0", - "illuminate/validation": "^11.45.3|^12.41.1|^13.0", - "php": "^8.2", - "symfony/process": "^7.4.5|^8.0.5" - }, - "require-dev": { - "jasonmccreary/double": "^1.0", - "laravel/pint": "^1.20", - "orchestra/testbench": "^9.15|^10.8|^11.0", - "pestphp/pest": "^3.8.5|^4.3.2", - "phpstan/phpstan": "^2.1.27", - "rector/rector": "^2.2.4" - }, - "type": "library", - "extra": { - "laravel": { - "aliases": { - "Mcp": "Laravel\\Mcp\\Facades\\Mcp" - }, - "providers": [ - "Laravel\\Mcp\\Server\\McpServiceProvider" - ] - } - }, - "autoload": { - "psr-4": { - "Laravel\\Mcp\\": "src/", - "Laravel\\Mcp\\Server\\": "src/Server/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Taylor Otwell", - "email": "taylor@laravel.com" - } - ], - "description": "Rapidly build MCP servers for your Laravel applications.", - "homepage": "https://github.com/laravel/mcp", - "keywords": [ - "laravel", - "mcp" - ], - "support": { - "issues": "https://github.com/laravel/mcp/issues", - "source": "https://github.com/laravel/mcp" - }, - "time": "2026-10-11T20:52:46+00:00" - }, { "name": "laravel/pail", "version": "v1.2.7", diff --git a/config/auth.php b/config/auth.php index a92793724..e6db78e8f 100644 --- a/config/auth.php +++ b/config/auth.php @@ -44,6 +44,11 @@ 'driver' => 'session', 'provider' => 'users', ], + + 'api' => [ + 'driver' => 'passport', + 'provider' => 'users', + ], ], /* diff --git a/database/migrations/2026_10_15_183803_create_oauth_auth_codes_table.php b/database/migrations/2026_10_15_183803_create_oauth_auth_codes_table.php new file mode 100644 index 000000000..244fcbc80 --- /dev/null +++ b/database/migrations/2026_10_15_183803_create_oauth_auth_codes_table.php @@ -0,0 +1,41 @@ +char('id', 80)->primary(); + $table->foreignUuid('user_id')->index(); + $table->foreignUuid('client_id'); + $table->text('scopes')->nullable(); + $table->boolean('revoked'); + $table->dateTime('expires_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_auth_codes'); + } + + /** + * Get the migration connection name. + */ + public function getConnection(): ?string + { + return $this->connection ?? config('passport.connection'); + } +}; diff --git a/database/migrations/2026_10_15_183804_create_oauth_access_tokens_table.php b/database/migrations/2026_10_15_183804_create_oauth_access_tokens_table.php new file mode 100644 index 000000000..f7f2405b2 --- /dev/null +++ b/database/migrations/2026_10_15_183804_create_oauth_access_tokens_table.php @@ -0,0 +1,43 @@ +char('id', 80)->primary(); + $table->foreignUuid('user_id')->nullable()->index(); + $table->foreignUuid('client_id'); + $table->string('name')->nullable(); + $table->text('scopes')->nullable(); + $table->boolean('revoked'); + $table->timestamps(); + $table->dateTime('expires_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_access_tokens'); + } + + /** + * Get the migration connection name. + */ + public function getConnection(): ?string + { + return $this->connection ?? config('passport.connection'); + } +}; diff --git a/database/migrations/2026_10_15_183805_create_oauth_refresh_tokens_table.php b/database/migrations/2026_10_15_183805_create_oauth_refresh_tokens_table.php new file mode 100644 index 000000000..5c926f4db --- /dev/null +++ b/database/migrations/2026_10_15_183805_create_oauth_refresh_tokens_table.php @@ -0,0 +1,39 @@ +char('id', 80)->primary(); + $table->char('access_token_id', 80)->index(); + $table->boolean('revoked'); + $table->dateTime('expires_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_refresh_tokens'); + } + + /** + * Get the migration connection name. + */ + public function getConnection(): ?string + { + return $this->connection ?? config('passport.connection'); + } +}; diff --git a/database/migrations/2026_10_15_183806_create_oauth_clients_table.php b/database/migrations/2026_10_15_183806_create_oauth_clients_table.php new file mode 100644 index 000000000..64903ac28 --- /dev/null +++ b/database/migrations/2026_10_15_183806_create_oauth_clients_table.php @@ -0,0 +1,44 @@ +uuid('id')->primary(); + $table->nullableUuidMorphs('owner'); + $table->string('name'); + $table->string('secret')->nullable(); + $table->string('provider')->nullable(); + $table->text('redirect_uris'); + $table->text('grant_types'); + $table->boolean('revoked'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_clients'); + } + + /** + * Get the migration connection name. + */ + public function getConnection(): ?string + { + return $this->connection ?? config('passport.connection'); + } +}; diff --git a/database/migrations/2026_10_15_183807_create_oauth_device_codes_table.php b/database/migrations/2026_10_15_183807_create_oauth_device_codes_table.php new file mode 100644 index 000000000..2e0df198d --- /dev/null +++ b/database/migrations/2026_10_15_183807_create_oauth_device_codes_table.php @@ -0,0 +1,44 @@ +char('id', 80)->primary(); + $table->foreignUuid('user_id')->nullable()->index(); + $table->foreignUuid('client_id')->index(); + $table->char('user_code', 8)->unique(); + $table->text('scopes'); + $table->boolean('revoked'); + $table->dateTime('user_approved_at')->nullable(); + $table->dateTime('last_polled_at')->nullable(); + $table->dateTime('expires_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_device_codes'); + } + + /** + * Get the migration connection name. + */ + public function getConnection(): ?string + { + return $this->connection ?? config('passport.connection'); + } +}; diff --git a/resources/js/pages/oauth/authorize.tsx b/resources/js/pages/oauth/authorize.tsx new file mode 100644 index 000000000..a37767a69 --- /dev/null +++ b/resources/js/pages/oauth/authorize.tsx @@ -0,0 +1,85 @@ +import { Head, usePage } from '@inertiajs/react'; +import AppLogo from '@/components/app-logo'; +import { Button } from '@/components/ui/button'; +import type { Profile } from '@/types'; + +type Props = { + client: string; + scopes: string[]; + authToken: string; + state: string | null; + clientId: string; + csrfToken: string; + url: string; +}; + +function Fields({ + authToken, + state, + clientId, + csrfToken, +}: Pick) { + return ( + <> + + + + {state !== null && ( + + )} + + ); +} + +export default function Authorize({ + client, + scopes, + authToken, + state, + clientId, + csrfToken, + url, +}: Props) { + const user: Profile = usePage().props.auth.user; + const fields = { authToken, state, clientId, csrfToken }; + + return ( + <> + + +
+ + +
+

+ Let {client} use your devnull account? +

+

+ You are signed in as @{user.username}. {client} will be + able to: +

+
    + {scopes.map((scope) => ( +
  • {scope}
  • + ))} +
+
+ + {/* Plain forms: the browser has to follow the redirect back to the client itself. */} +
+
+ + + +
+ + + + +
+
+ + ); +} diff --git a/routes/ai.php b/routes/ai.php new file mode 100644 index 000000000..5b2d042b0 --- /dev/null +++ b/routes/ai.php @@ -0,0 +1,11 @@ +middleware(['auth:api', 'throttle:mcp']); diff --git a/tests/Feature/Mcp/DevNullServerTest.php b/tests/Feature/Mcp/DevNullServerTest.php new file mode 100644 index 000000000..9b18a4312 --- /dev/null +++ b/tests/Feature/Mcp/DevNullServerTest.php @@ -0,0 +1,202 @@ +create(); + $followed = User::factory()->create(); + $user->following()->attach($followed); + + Commit::factory()->for($followed)->create(['body' => 'From someone followed']); + Commit::factory()->create(['body' => 'From a stranger']); + + DevNullServer::actingAs($user) + ->tool(ShowLogTool::class) + ->assertOk() + ->assertSee('From someone followed') + ->assertDontSee('From a stranger'); +}); + +it('shows the commits of everyone', function (): void { + $user = User::factory()->create(); + + Commit::factory()->create(['body' => 'From a stranger']); + Commit::factory()->pending()->create(['body' => 'Still pending']); + + DevNullServer::actingAs($user) + ->tool(ShowLogTool::class, ['log' => 'everyone']) + ->assertOk() + ->assertSee('From a stranger') + ->assertDontSee('Still pending'); +}); + +it('pages through the log with a cursor', function (): void { + $user = User::factory()->create(); + + Commit::factory()->count(21)->create(); + + DevNullServer::actingAs($user) + ->tool(ShowLogTool::class, ['log' => 'everyone']) + ->assertStructuredContent(fn ($json) => $json + ->count('commits', 20) + ->whereType('next_cursor', 'string') + ->etc()); +}); + +it('shows a commit by its hash with its thread', function (): void { + $user = User::factory()->create(); + $parent = Commit::factory()->create(['body' => 'The parent']); + $commit = Commit::factory()->for($parent, 'parent')->create(['body' => 'The commit']); + Commit::factory()->for($commit, 'parent')->create(['body' => 'A reply']); + + DevNullServer::actingAs($user) + ->tool(ShowCommitTool::class, ['commit' => mb_substr($commit->id, -7)]) + ->assertOk() + ->assertSee(['The commit', 'The parent', 'A reply']); +}); + +it('cannot show a pending commit of someone else', function (): void { + $user = User::factory()->create(); + $commit = Commit::factory()->pending()->create(); + + DevNullServer::actingAs($user) + ->tool(ShowCommitTool::class, ['commit' => $commit->id]) + ->assertHasErrors(['No commit found for that reference.']); +}); + +it('rejects references that are no commit', function (): void { + $user = User::factory()->create(); + + DevNullServer::actingAs($user) + ->tool(ShowCommitTool::class, ['commit' => 'not a commit']) + ->assertHasErrors(['That is not a commit hash, ID, or link.']); +}); + +it('finds users', function (): void { + $user = User::factory()->create(); + User::factory()->create(['username' => 'taylorotwell']); + + DevNullServer::actingAs($user) + ->tool(ListUsersTool::class, ['query' => '@taylor']) + ->assertOk() + ->assertSee('taylorotwell'); +}); + +it('commits as the user', function (): void { + $user = User::factory()->create(); + + DevNullServer::actingAs($user) + ->tool(CreateCommitTool::class, ['body' => 'Hello from an agent']) + ->assertOk() + ->assertStructuredContent(fn ($json) => $json + ->where('pending_review', true) + ->where('commit.body', 'Hello from an agent') + ->etc()); + + $commit = $user->commits()->sole(); + + expect($commit->status)->toBe(CommitStatus::Pending); +}); + +it('replies to a visible commit', function (): void { + $user = User::factory()->create(); + $parent = Commit::factory()->create(); + + DevNullServer::actingAs($user) + ->tool(CreateCommitTool::class, ['body' => 'A reply', 'parent_id' => $parent->id]) + ->assertOk(); + + expect($user->commits()->sole()->parent_id)->toBe($parent->id); +}); + +it('cannot reply to a pending commit of someone else', function (): void { + $user = User::factory()->create(); + $parent = Commit::factory()->pending()->create(); + + DevNullServer::actingAs($user) + ->tool(CreateCommitTool::class, ['body' => 'A reply', 'parent_id' => $parent->id]) + ->assertHasErrors(['That commit is no longer available to reply to.']); + + expect($user->commits()->exists())->toBeFalse(); +}); + +it('validates the body of a commit', function (): void { + $user = User::factory()->create(); + + DevNullServer::actingAs($user) + ->tool(CreateCommitTool::class, ['body' => str_repeat('a', 281)]) + ->assertHasErrors(); + + expect($user->commits()->exists())->toBeFalse(); +}); + +it('commits at most 10 times a minute', function (): void { + $user = User::factory()->create(); + + RateLimiter::increment("mcp-commits:{$user->id}", amount: 10); + + DevNullServer::actingAs($user) + ->tool(CreateCommitTool::class, ['body' => 'One too many']) + ->assertHasErrors(['Slow down: 10 commits a minute. Even CI takes breaks.']); + + expect($user->commits()->exists())->toBeFalse(); +}); + +it('stars a commit', function (): void { + $user = User::factory()->create(); + $commit = Commit::factory()->create(); + + DevNullServer::actingAs($user) + ->tool(CreateCommitStarTool::class, ['commit' => $commit->id]) + ->assertOk(); + + expect($user->hasStarred($commit))->toBeTrue(); +}); + +it('cannot star a pending commit of someone else', function (): void { + $user = User::factory()->create(); + $commit = Commit::factory()->pending()->create(); + + DevNullServer::actingAs($user) + ->tool(CreateCommitStarTool::class, ['commit' => $commit->id]) + ->assertHasErrors(); + + expect($user->hasStarred($commit))->toBeFalse(); +}); + +it('follows a user', function (): void { + $user = User::factory()->create(); + $other = User::factory()->create(['username' => 'someone']); + + DevNullServer::actingAs($user) + ->tool(CreateUserFollowTool::class, ['username' => '@someone']) + ->assertOk(); + + expect($user->following()->whereKey($other->id)->exists())->toBeTrue(); +}); + +it('cannot follow themselves or unknown users', function (): void { + $user = User::factory()->create(['username' => 'me']); + + DevNullServer::actingAs($user) + ->tool(CreateUserFollowTool::class, ['username' => 'me']) + ->assertHasErrors(['You cannot follow yourself.']); + + DevNullServer::actingAs($user) + ->tool(CreateUserFollowTool::class, ['username' => 'nobody']) + ->assertHasErrors(['No user found with that username.']); + + expect($user->following()->exists())->toBeFalse(); +}); diff --git a/tests/Feature/Mcp/McpAuthenticationTest.php b/tests/Feature/Mcp/McpAuthenticationTest.php new file mode 100644 index 000000000..235d988d7 --- /dev/null +++ b/tests/Feature/Mcp/McpAuthenticationTest.php @@ -0,0 +1,107 @@ + 2048]); + openssl_pkey_export($key, $privateKey); + + config([ + 'passport.private_key' => $privateKey, + 'passport.public_key' => openssl_pkey_get_details($key)['key'], + ]); +}); + +function initializeRequest(): array +{ + return [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + 'params' => [ + 'protocolVersion' => '2025-06-18', + 'capabilities' => (object) [], + 'clientInfo' => ['name' => 'test', 'version' => '1.0.0'], + ], + ]; +} + +it('requires a bearer token and points clients at the OAuth metadata', function (): void { + $response = $this->postJson('/mcp', initializeRequest()); + + $response->assertUnauthorized(); + + expect($response->headers->get('WWW-Authenticate'))->toContain('resource_metadata='); +}); + +it('serves the MCP server to users with an access token', function (): void { + Passport::actingAs(User::factory()->create(), ['mcp:use']); + + $this->postJson('/mcp', initializeRequest()) + ->assertOk() + ->assertJsonPath('result.serverInfo.name', 'devnull'); +}); + +it('advertises Passport as the authorization server', function (): void { + $this->getJson('/.well-known/oauth-authorization-server') + ->assertOk() + ->assertJsonPath('authorization_endpoint', route('passport.authorizations.authorize')) + ->assertJsonPath('token_endpoint', route('passport.token')) + ->assertJsonPath('registration_endpoint', route('mcp.oauth.register')); +}); + +it('lets clients register themselves', function (): void { + $this->postJson(route('mcp.oauth.register'), [ + 'client_name' => 'Some Agent', + 'redirect_uris' => ['https://agent.example.com/callback'], + ]) + ->assertCreated() + ->assertJsonStructure(['client_id']); +}); + +it('asks signed in users for consent before authorizing a client', function (): void { + $user = User::factory()->create(); + $client = resolve(ClientRepository::class)->createAuthorizationCodeGrantClient( + 'Some Agent', + ['https://agent.example.com/callback'], + confidential: false, + ); + + $this->actingAs($user) + ->get(route('passport.authorizations.authorize', [ + 'client_id' => $client->getKey(), + 'redirect_uri' => 'https://agent.example.com/callback', + 'response_type' => 'code', + 'scope' => 'mcp:use', + 'state' => 'abc', + 'code_challenge' => str_repeat('a', 43), + 'code_challenge_method' => 'S256', + ])) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('oauth/authorize') + ->where('client', 'Some Agent') + ->where('state', 'abc') + ->where('scopes', ['Use MCP server'])); +}); + +it('sends guests to sign in first', function (): void { + $client = resolve(ClientRepository::class)->createAuthorizationCodeGrantClient( + 'Some Agent', + ['https://agent.example.com/callback'], + confidential: false, + ); + + $this->get(route('passport.authorizations.authorize', [ + 'client_id' => $client->getKey(), + 'redirect_uri' => 'https://agent.example.com/callback', + 'response_type' => 'code', + 'scope' => 'mcp:use', + 'code_challenge' => str_repeat('a', 43), + 'code_challenge_method' => 'S256', + ]))->assertRedirectToRoute('home'); +}); From 317ef91450f67d0c152f5c0fd5c02fa736778a24 Mon Sep 17 00:00:00 2001 From: Kjell Kind Date: Thu, 15 Oct 2026 20:51:11 +0200 Subject: [PATCH 2/3] fix: keep the OAuth consent page from reloading itself The deferred suggestedUsers shared prop made the consent page issue a second GET to /oauth/authorize, which replaced the session auth token and made approving fail with a 403. Co-Authored-By: Claude Sonnet 5.5 --- app/Providers/AppServiceProvider.php | 5 +++ tests/Feature/Mcp/McpAuthenticationTest.php | 34 +++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 7e3d39312..0c66af358 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -35,9 +35,14 @@ public function boot(): void $this->bootPassport(); } + /** + * Every visit to the consent page issues a new auth token, so the page must not reload itself (as it would for + * deferred shared props): the form would then hold a token the session no longer knows. + */ private function bootPassport(): void { Passport::authorizationView(fn (array $parameters): InertiaResponse => Inertia::render('oauth/authorize', [ + 'suggestedUsers' => null, 'client' => $parameters['client']->name, 'scopes' => array_map(fn (Scope $scope): string => $scope->description, $parameters['scopes']), 'authToken' => $parameters['authToken'], diff --git a/tests/Feature/Mcp/McpAuthenticationTest.php b/tests/Feature/Mcp/McpAuthenticationTest.php index 235d988d7..eb5b4898e 100644 --- a/tests/Feature/Mcp/McpAuthenticationTest.php +++ b/tests/Feature/Mcp/McpAuthenticationTest.php @@ -89,6 +89,40 @@ function initializeRequest(): array ->where('scopes', ['Use MCP server'])); }); +it('lets signed in users approve a client with the token on the consent page', function (): void { + $user = User::factory()->create(); + $client = resolve(ClientRepository::class)->createAuthorizationCodeGrantClient( + 'Some Agent', + ['http://localhost:5555/callback'], + confidential: false, + ); + + $page = $this->actingAs($user)->get(route('passport.authorizations.authorize', [ + 'client_id' => $client->getKey(), + 'redirect_uri' => 'http://localhost:5555/callback', + 'response_type' => 'code', + 'scope' => 'mcp:use', + 'state' => 'abc', + 'code_challenge' => str_repeat('a', 43), + 'code_challenge_method' => 'S256', + ])); + + $payload = $page->viewData('page'); + + expect($payload)->not->toHaveKey('deferredProps'); + + $props = $payload['props']; + + $response = $this->post(route('passport.authorizations.approve'), [ + 'auth_token' => $props['authToken'], + 'client_id' => $props['clientId'], + 'state' => 'abc', + ]); + + $response->assertRedirectContains('http://localhost:5555/callback?code='); + expect($response->headers->get('Location'))->toContain('state=abc'); +}); + it('sends guests to sign in first', function (): void { $client = resolve(ClientRepository::class)->createAuthorizationCodeGrantClient( 'Some Agent', From 0281eeef9bd0986a05ab144d3146d3ba72eba996 Mon Sep 17 00:00:00 2001 From: Kjell Kind Date: Mon, 19 Oct 2026 15:32:17 +0200 Subject: [PATCH 3/3] fix: let MCP commits be as long as other commits and pass CI MCP commits now take the same 4,000 characters as the app, with blank lines collapsed first. The consent page reads the user through useUser, PHPStan's errors in the MCP code are typed away, and tests cover the tool schemas, deleted commits in a thread and ambiguous hashes. Co-Authored-By: Claude Opus 5.5 --- app/Mcp/CommitResolver.php | 3 +- app/Mcp/Presenter.php | 2 +- app/Mcp/Servers/DevNullServer.php | 2 +- app/Mcp/Tools/CreateCommitStarTool.php | 1 + app/Mcp/Tools/CreateCommitTool.php | 12 ++++-- app/Mcp/Tools/CreateUserFollowTool.php | 1 + app/Mcp/Tools/ListUsersTool.php | 3 +- app/Mcp/Tools/ShowCommitTool.php | 5 +-- app/Mcp/Tools/ShowLogTool.php | 3 +- app/Providers/AppServiceProvider.php | 28 ++++++++------ ...5_183803_create_oauth_auth_codes_table.php | 10 +---- ...83804_create_oauth_access_tokens_table.php | 10 +---- ...3805_create_oauth_refresh_tokens_table.php | 10 +---- ...0_15_183806_create_oauth_clients_table.php | 10 +---- ...183807_create_oauth_device_codes_table.php | 10 +---- resources/js/pages/oauth/authorize.tsx | 6 +-- tests/Feature/Mcp/DevNullServerTest.php | 38 ++++++++++++++++++- tests/Feature/Mcp/McpAuthenticationTest.php | 10 +++++ 18 files changed, 92 insertions(+), 72 deletions(-) diff --git a/app/Mcp/CommitResolver.php b/app/Mcp/CommitResolver.php index 8d712f56f..46817b900 100644 --- a/app/Mcp/CommitResolver.php +++ b/app/Mcp/CommitResolver.php @@ -7,6 +7,7 @@ use App\Models\Commit; use App\Models\User; use App\Queries\CommitByReferenceQuery; +use Illuminate\Database\Eloquent\Collection; use Laravel\Mcp\Response; final readonly class CommitResolver @@ -23,7 +24,7 @@ public function resolve(User $user, string $reference): Commit|Response { $commits = $this->query->get($user, mb_trim($reference)); - if ($commits === null) { + if (! $commits instanceof Collection) { return Response::error('That is not a commit hash, ID, or link.'); } diff --git a/app/Mcp/Presenter.php b/app/Mcp/Presenter.php index 993322bae..37a45c55f 100644 --- a/app/Mcp/Presenter.php +++ b/app/Mcp/Presenter.php @@ -26,7 +26,7 @@ public function commit(Commit $commit): array 'id' => $commit->id, 'hash' => $this->hash($commit), 'url' => route('commit.show', [$commit->user, $commit]), - 'author' => $commit->user->username, + 'author' => $commit->user?->username, 'body' => $commit->body, 'status' => $commit->status->value, 'parent_id' => $commit->parent_id, diff --git a/app/Mcp/Servers/DevNullServer.php b/app/Mcp/Servers/DevNullServer.php index 1f0f4d709..3aff5cb09 100644 --- a/app/Mcp/Servers/DevNullServer.php +++ b/app/Mcp/Servers/DevNullServer.php @@ -17,7 +17,7 @@ #[Name('devnull')] #[Version('1.0.0')] -#[Instructions('devnull is a social feed where developers share short commits (280 characters at most). Read the log of people the user follows or of everyone, open a commit with its replies, find people, and, as the signed-in user, commit, reply, star and follow. New commits from non-admins are pending until a moderator approves them. A commit is referenced by its hash (the last 7 characters of its ID), its full ID, or its link.')] +#[Instructions('devnull is a social feed where developers share commits (4,000 characters at most). Read the latest or trending log, or the log of people the user follows, open a commit with its replies, find people, and, as the signed-in user, commit, reply, star and follow. New commits from non-admins are pending until a moderator approves them. A commit is referenced by its hash (the last 7 characters of its ID), its full ID, or its link.')] final class DevNullServer extends Server { protected array $tools = [ diff --git a/app/Mcp/Tools/CreateCommitStarTool.php b/app/Mcp/Tools/CreateCommitStarTool.php index c2b6d4f5a..4715ab349 100644 --- a/app/Mcp/Tools/CreateCommitStarTool.php +++ b/app/Mcp/Tools/CreateCommitStarTool.php @@ -24,6 +24,7 @@ public function handle(Request $request, CommitResolver $resolver, CreateCommitS $user = $request->user(); assert($user instanceof User); + /** @var array{commit: string} $validated */ $validated = $request->validate([ 'commit' => ['required', 'string', 'max:300'], ]); diff --git a/app/Mcp/Tools/CreateCommitTool.php b/app/Mcp/Tools/CreateCommitTool.php index 9facee6f7..b110aaef8 100644 --- a/app/Mcp/Tools/CreateCommitTool.php +++ b/app/Mcp/Tools/CreateCommitTool.php @@ -10,6 +10,7 @@ use App\Models\Commit; use App\Models\User; use App\Rules\VisibleCommit; +use App\Services\CommitBodies; use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\Support\Facades\RateLimiter; use Laravel\Mcp\Request; @@ -23,13 +24,18 @@ final class CreateCommitTool extends Tool { private const int MAX_ATTEMPTS = 10; - public function handle(Request $request, Presenter $presenter, CreateCommit $action): Response|ResponseFactory + public function handle(Request $request, Presenter $presenter, CommitBodies $bodies, CreateCommit $action): Response|ResponseFactory { $user = $request->user(); assert($user instanceof User); + if (is_string($request->get('body'))) { + $request->merge(['body' => $bodies->normalize($request->get('body'))]); + } + + /** @var array{body: string, parent_id?: string|null} $validated */ $validated = $request->validate([ - 'body' => ['required', 'string', 'min:3', 'max:280'], + 'body' => ['required', 'string', 'min:'.Commit::MIN_BODY_LENGTH, 'max:'.Commit::MAX_BODY_LENGTH], 'parent_id' => ['nullable', 'uuid', new VisibleCommit($user)], ]); @@ -59,7 +65,7 @@ public function handle(Request $request, Presenter $presenter, CreateCommit $act public function schema(JsonSchema $schema): array { return [ - 'body' => $schema->string()->description('What to commit: 3 to 280 characters.')->required(), + 'body' => $schema->string()->description(sprintf('What to commit: %d to %s characters.', Commit::MIN_BODY_LENGTH, number_format(Commit::MAX_BODY_LENGTH)))->required(), 'parent_id' => $schema->string()->description('The full ID of the commit to reply to. Leave out for a new commit.'), ]; } diff --git a/app/Mcp/Tools/CreateUserFollowTool.php b/app/Mcp/Tools/CreateUserFollowTool.php index 61eb7c869..a8d6aeb30 100644 --- a/app/Mcp/Tools/CreateUserFollowTool.php +++ b/app/Mcp/Tools/CreateUserFollowTool.php @@ -22,6 +22,7 @@ public function handle(Request $request, CreateUserFollow $action): Response $follower = $request->user(); assert($follower instanceof User); + /** @var array{username: string} $validated */ $validated = $request->validate([ 'username' => ['required', 'string', 'max:100'], ]); diff --git a/app/Mcp/Tools/ListUsersTool.php b/app/Mcp/Tools/ListUsersTool.php index 4e9df2200..7bcac9410 100644 --- a/app/Mcp/Tools/ListUsersTool.php +++ b/app/Mcp/Tools/ListUsersTool.php @@ -19,11 +19,12 @@ #[IsReadOnly] final class ListUsersTool extends Tool { - public function handle(Request $request, Presenter $presenter, UserSearchQuery $query): Response|ResponseFactory + public function handle(Request $request, Presenter $presenter, UserSearchQuery $query): ResponseFactory { $user = $request->user(); assert($user instanceof User); + /** @var array{query: string} $validated */ $validated = $request->validate([ 'query' => ['required', 'string', 'max:100'], ]); diff --git a/app/Mcp/Tools/ShowCommitTool.php b/app/Mcp/Tools/ShowCommitTool.php index 2b728f677..0d0e77186 100644 --- a/app/Mcp/Tools/ShowCommitTool.php +++ b/app/Mcp/Tools/ShowCommitTool.php @@ -27,6 +27,7 @@ public function handle(Request $request, Presenter $presenter, CommitResolver $r $user = $request->user(); assert($user instanceof User); + /** @var array{commit: string} $validated */ $validated = $request->validate([ 'commit' => ['required', 'string', 'max:300'], ]); @@ -49,9 +50,7 @@ public function handle(Request $request, Presenter $presenter, CommitResolver $r ->map(fn (Commit $ancestor): array => $presenter->commit($ancestor)) ->values() ->all(), - 'replies' => $replies->paginate($commit, $user)->getCollection() - ->map(fn (Commit $reply): array => $presenter->commit($reply)) - ->all(), + 'replies' => array_map($presenter->commit(...), $replies->paginate($commit, $user)->items()), ]); } diff --git a/app/Mcp/Tools/ShowLogTool.php b/app/Mcp/Tools/ShowLogTool.php index 2e71f50d8..23b80b784 100644 --- a/app/Mcp/Tools/ShowLogTool.php +++ b/app/Mcp/Tools/ShowLogTool.php @@ -23,11 +23,12 @@ final class ShowLogTool extends Tool { private const int PER_PAGE = 20; - public function handle(Request $request, Presenter $presenter): Response|ResponseFactory + public function handle(Request $request, Presenter $presenter): ResponseFactory { $user = $request->user(); assert($user instanceof User); + /** @var array{log?: string, cursor?: string|null} $validated */ $validated = $request->validate([ 'log' => ['sometimes', Rule::enum(Log::class)], 'cursor' => ['nullable', 'string'], diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 4b2cf66a3..be524ab4d 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -18,9 +18,10 @@ use Illuminate\Support\ServiceProvider; use Inertia\ExceptionResponse; use Inertia\Inertia; -use Inertia\Response as InertiaResponse; +use Laravel\Passport\Client; use Laravel\Passport\Passport; use Laravel\Passport\Scope; +use Symfony\Component\HttpFoundation\Response; use Tempest\Highlight\Highlighter; final class AppServiceProvider extends ServiceProvider @@ -47,16 +48,19 @@ public function boot(): void */ private function bootPassport(): void { - Passport::authorizationView(fn (array $parameters): InertiaResponse => Inertia::render('oauth/authorize', [ - 'suggestedUsers' => null, - 'client' => $parameters['client']->name, - 'scopes' => array_map(fn (Scope $scope): string => $scope->description, $parameters['scopes']), - 'authToken' => $parameters['authToken'], - 'state' => $parameters['request']->input('state'), - 'clientId' => $parameters['client']->getKey(), - 'csrfToken' => csrf_token(), - 'url' => route('passport.authorizations.approve'), - ])); + Passport::authorizationView(function (array $parameters): Response { + /** @var array{client: Client, scopes: list, authToken: string, request: Request} $parameters */ + return Inertia::render('oauth/authorize', [ + 'suggestedUsers' => null, + 'client' => $parameters['client']->name, + 'scopes' => array_map(fn (Scope $scope): string => $scope->description, $parameters['scopes']), + 'authToken' => $parameters['authToken'], + 'state' => $parameters['request']->input('state'), + 'clientId' => $parameters['client']->getKey(), + 'csrfToken' => csrf_token(), + 'url' => route('passport.authorizations.approve'), + ])->toResponse($parameters['request']); + }); } private function bootExceptionHandling(): void @@ -92,7 +96,7 @@ private function bootExceptionHandling(): void private function bootRateLimitingDefaults(): void { - RateLimiter::for('mcp', fn (Request $request): Limit => Limit::perMinute(60)->by($request->user()?->id ?? $request->ip())); + RateLimiter::for('mcp', fn (Request $request): Limit => Limit::perMinute(60)->by($this->userId($request))); RateLimiter::for('commit-reports', fn (Request $request): Limit => Limit::perMinute(5)->by($this->userId($request))); diff --git a/database/migrations/2026_10_15_183803_create_oauth_auth_codes_table.php b/database/migrations/2026_10_15_183803_create_oauth_auth_codes_table.php index 6052e4cb6..7065ae9a0 100644 --- a/database/migrations/2026_10_15_183803_create_oauth_auth_codes_table.php +++ b/database/migrations/2026_10_15_183803_create_oauth_auth_codes_table.php @@ -10,7 +10,7 @@ { public function up(): void { - Schema::create('oauth_auth_codes', function (Blueprint $table) { + Schema::create('oauth_auth_codes', function (Blueprint $table): void { $table->char('id', 80)->primary(); $table->foreignUuid('user_id')->index(); $table->foreignUuid('client_id'); @@ -19,12 +19,4 @@ public function up(): void $table->dateTime('expires_at')->nullable(); }); } - - /** - * Get the migration connection name. - */ - public function getConnection(): ?string - { - return $this->connection ?? config('passport.connection'); - } }; diff --git a/database/migrations/2026_10_15_183804_create_oauth_access_tokens_table.php b/database/migrations/2026_10_15_183804_create_oauth_access_tokens_table.php index d820ed1f4..3a4f71b86 100644 --- a/database/migrations/2026_10_15_183804_create_oauth_access_tokens_table.php +++ b/database/migrations/2026_10_15_183804_create_oauth_access_tokens_table.php @@ -10,7 +10,7 @@ { public function up(): void { - Schema::create('oauth_access_tokens', function (Blueprint $table) { + Schema::create('oauth_access_tokens', function (Blueprint $table): void { $table->char('id', 80)->primary(); $table->foreignUuid('user_id')->nullable()->index(); $table->foreignUuid('client_id'); @@ -21,12 +21,4 @@ public function up(): void $table->dateTime('expires_at')->nullable(); }); } - - /** - * Get the migration connection name. - */ - public function getConnection(): ?string - { - return $this->connection ?? config('passport.connection'); - } }; diff --git a/database/migrations/2026_10_15_183805_create_oauth_refresh_tokens_table.php b/database/migrations/2026_10_15_183805_create_oauth_refresh_tokens_table.php index 665925888..0fc5d0f4c 100644 --- a/database/migrations/2026_10_15_183805_create_oauth_refresh_tokens_table.php +++ b/database/migrations/2026_10_15_183805_create_oauth_refresh_tokens_table.php @@ -10,19 +10,11 @@ { public function up(): void { - Schema::create('oauth_refresh_tokens', function (Blueprint $table) { + Schema::create('oauth_refresh_tokens', function (Blueprint $table): void { $table->char('id', 80)->primary(); $table->char('access_token_id', 80)->index(); $table->boolean('revoked'); $table->dateTime('expires_at')->nullable(); }); } - - /** - * Get the migration connection name. - */ - public function getConnection(): ?string - { - return $this->connection ?? config('passport.connection'); - } }; diff --git a/database/migrations/2026_10_15_183806_create_oauth_clients_table.php b/database/migrations/2026_10_15_183806_create_oauth_clients_table.php index 6f64a220e..96e9d4098 100644 --- a/database/migrations/2026_10_15_183806_create_oauth_clients_table.php +++ b/database/migrations/2026_10_15_183806_create_oauth_clients_table.php @@ -10,7 +10,7 @@ { public function up(): void { - Schema::create('oauth_clients', function (Blueprint $table) { + Schema::create('oauth_clients', function (Blueprint $table): void { $table->uuid('id')->primary(); $table->nullableUuidMorphs('owner'); $table->string('name'); @@ -22,12 +22,4 @@ public function up(): void $table->timestamps(); }); } - - /** - * Get the migration connection name. - */ - public function getConnection(): ?string - { - return $this->connection ?? config('passport.connection'); - } }; diff --git a/database/migrations/2026_10_15_183807_create_oauth_device_codes_table.php b/database/migrations/2026_10_15_183807_create_oauth_device_codes_table.php index fccc8caa1..d03476d82 100644 --- a/database/migrations/2026_10_15_183807_create_oauth_device_codes_table.php +++ b/database/migrations/2026_10_15_183807_create_oauth_device_codes_table.php @@ -10,7 +10,7 @@ { public function up(): void { - Schema::create('oauth_device_codes', function (Blueprint $table) { + Schema::create('oauth_device_codes', function (Blueprint $table): void { $table->char('id', 80)->primary(); $table->foreignUuid('user_id')->nullable()->index(); $table->foreignUuid('client_id')->index(); @@ -22,12 +22,4 @@ public function up(): void $table->dateTime('expires_at')->nullable(); }); } - - /** - * Get the migration connection name. - */ - public function getConnection(): ?string - { - return $this->connection ?? config('passport.connection'); - } }; diff --git a/resources/js/pages/oauth/authorize.tsx b/resources/js/pages/oauth/authorize.tsx index a37767a69..d98fbf5a3 100644 --- a/resources/js/pages/oauth/authorize.tsx +++ b/resources/js/pages/oauth/authorize.tsx @@ -1,7 +1,7 @@ -import { Head, usePage } from '@inertiajs/react'; +import { Head } from '@inertiajs/react'; import AppLogo from '@/components/app-logo'; import { Button } from '@/components/ui/button'; -import type { Profile } from '@/types'; +import { useUser } from '@/hooks/use-user'; type Props = { client: string; @@ -40,7 +40,7 @@ export default function Authorize({ csrfToken, url, }: Props) { - const user: Profile = usePage().props.auth.user; + const { user } = useUser(); const fields = { authToken, state, clientId, csrfToken }; return ( diff --git a/tests/Feature/Mcp/DevNullServerTest.php b/tests/Feature/Mcp/DevNullServerTest.php index 52b65e066..456ba7b65 100644 --- a/tests/Feature/Mcp/DevNullServerTest.php +++ b/tests/Feature/Mcp/DevNullServerTest.php @@ -67,6 +67,32 @@ ->assertSee(['The commit', 'The parent', 'A reply']); }); +it('shows a deleted commit in the thread as deleted', function (): void { + $user = User::factory()->create(); + $parent = Commit::factory()->create(['body' => 'The deleted parent']); + $commit = Commit::factory()->for($parent, 'parent')->create(); + $parent->delete(); + + DevNullServer::actingAs($user) + ->tool(ShowCommitTool::class, ['commit' => $commit->id]) + ->assertOk() + ->assertStructuredContent(fn ($json) => $json + ->where('ancestors.0.id', $parent->id) + ->where('ancestors.0.deleted', true) + ->etc()) + ->assertDontSee('The deleted parent'); +}); + +it('asks for the full ID when a hash matches more than one commit', function (): void { + $user = User::factory()->create(); + Commit::factory()->create(['id' => '01970000-0000-7000-8000-000011234567']); + Commit::factory()->create(['id' => '01970000-0000-7000-8000-000021234567']); + + DevNullServer::actingAs($user) + ->tool(ShowCommitTool::class, ['commit' => '1234567']) + ->assertHasErrors(['That hash matches more than one commit: use the full ID instead.']); +}); + it('cannot show a pending commit of someone else', function (): void { $user = User::factory()->create(); $commit = Commit::factory()->pending()->create(); @@ -132,11 +158,21 @@ expect($user->commits()->exists())->toBeFalse(); }); +it('commits a long body', function (): void { + $user = User::factory()->create(); + + DevNullServer::actingAs($user) + ->tool(CreateCommitTool::class, ['body' => str_repeat('a', Commit::MAX_BODY_LENGTH)]) + ->assertOk(); + + expect($user->commits()->sole()->body)->toHaveLength(Commit::MAX_BODY_LENGTH); +}); + it('validates the body of a commit', function (): void { $user = User::factory()->create(); DevNullServer::actingAs($user) - ->tool(CreateCommitTool::class, ['body' => str_repeat('a', 281)]) + ->tool(CreateCommitTool::class, ['body' => str_repeat('a', Commit::MAX_BODY_LENGTH + 1)]) ->assertHasErrors(); expect($user->commits()->exists())->toBeFalse(); diff --git a/tests/Feature/Mcp/McpAuthenticationTest.php b/tests/Feature/Mcp/McpAuthenticationTest.php index eb5b4898e..fdff93e52 100644 --- a/tests/Feature/Mcp/McpAuthenticationTest.php +++ b/tests/Feature/Mcp/McpAuthenticationTest.php @@ -46,6 +46,15 @@ function initializeRequest(): array ->assertJsonPath('result.serverInfo.name', 'devnull'); }); +it('lists the tools with what they take', function (): void { + Passport::actingAs(User::factory()->create(), ['mcp:use']); + + $this->postJson('/mcp', ['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list']) + ->assertOk() + ->assertJsonCount(6, 'result.tools') + ->assertJsonPath('result.tools.0.inputSchema.properties.log.enum', ['following', 'latest', 'trending']); +}); + it('advertises Passport as the authorization server', function (): void { $this->getJson('/.well-known/oauth-authorization-server') ->assertOk() @@ -120,6 +129,7 @@ function initializeRequest(): array ]); $response->assertRedirectContains('http://localhost:5555/callback?code='); + expect($response->headers->get('Location'))->toContain('state=abc'); });