diff --git a/app/Mcp/CommitResolver.php b/app/Mcp/CommitResolver.php new file mode 100644 index 000000000..46817b900 --- /dev/null +++ b/app/Mcp/CommitResolver.php @@ -0,0 +1,37 @@ +query->get($user, mb_trim($reference)); + + if (! $commits instanceof Collection) { + 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..37a45c55f --- /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..3aff5cb09 --- /dev/null +++ b/app/Mcp/Servers/DevNullServer.php @@ -0,0 +1,39 @@ +user(); + assert($user instanceof User); + + /** @var array{commit: string} $validated */ + $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..b110aaef8 --- /dev/null +++ b/app/Mcp/Tools/CreateCommitTool.php @@ -0,0 +1,72 @@ +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:'.Commit::MIN_BODY_LENGTH, 'max:'.Commit::MAX_BODY_LENGTH], + '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(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 new file mode 100644 index 000000000..a8d6aeb30 --- /dev/null +++ b/app/Mcp/Tools/CreateUserFollowTool.php @@ -0,0 +1,54 @@ +user(); + assert($follower instanceof User); + + /** @var array{username: string} $validated */ + $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..7bcac9410 --- /dev/null +++ b/app/Mcp/Tools/ListUsersTool.php @@ -0,0 +1,48 @@ +user(); + assert($user instanceof User); + + /** @var array{query: string} $validated */ + $validated = $request->validate([ + 'query' => ['required', 'string', 'max:100'], + ]); + + return Response::structured([ + 'users' => $query->get($validated['query'], $user) + ->map(fn (User $found): array => $presenter->user($found)) + ->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..0d0e77186 --- /dev/null +++ b/app/Mcp/Tools/ShowCommitTool.php @@ -0,0 +1,68 @@ +user(); + assert($user instanceof User); + + /** @var array{commit: string} $validated */ + $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' => array_map($presenter->commit(...), $replies->paginate($commit, $user)->items()), + ]); + } + + /** + * @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..23b80b784 --- /dev/null +++ b/app/Mcp/Tools/ShowLogTool.php @@ -0,0 +1,64 @@ +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'], + ]); + + $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 4360ca6ed..472c57633 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -22,6 +22,8 @@ use Illuminate\Database\Query\Builder as QueryBuilder; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Support\Facades\DB; +use Laravel\Passport\Contracts\OAuthenticatable; +use Laravel\Passport\HasApiTokens; /** * @property-read string $id @@ -110,8 +112,10 @@ 'commit_rules_accepted_at', 'emails_unsubscribed_at', ])] -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 94933c657..7947ab89d 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -18,6 +18,10 @@ use Illuminate\Support\ServiceProvider; use Inertia\ExceptionResponse; use Inertia\Inertia; +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 @@ -26,6 +30,8 @@ public function register(): void { $this->app->bind(DnsResolver::class, SystemDnsResolver::class); $this->app->bind(Highlighter::class, fn (): Highlighter => new Highlighter(new CodeTheme())); + + Passport::$deviceCodeGrantEnabled = false; } public function boot(): void @@ -33,6 +39,28 @@ public function boot(): void $this->bootRateLimitingDefaults(); $this->bootExceptionHandling(); + $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(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 @@ -68,6 +96,8 @@ private function bootExceptionHandling(): void private function bootRateLimitingDefaults(): void { + 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))); // Every sync costs four GitHub API requests, out of a shared hourly budget. The mobile app shares it, and gets the diff --git a/composer.json b/composer.json index 7398bb672..e62772b9f 100644 --- a/composer.json +++ b/composer.json @@ -15,6 +15,8 @@ "intervention/image": "^4.4.0", "laravel/ai": "^1.3.0", "laravel/framework": "^13.38.0", + "laravel/mcp": "^1.1.1", + "laravel/passport": "^13.10", "laravel/socialite": "^5.31.0", "laravel/wayfinder": "^0.1.23", "league/flysystem-aws-s3-v3": "^3.35.3", diff --git a/composer.lock b/composer.lock index df440131b..a216df7e7 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": "f6112468a2fda879fb903b4c4f7c1c3d", + "content-hash": "3a07ebbc8a7299d93f428467c2aa9159", "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", @@ -1813,6 +1880,157 @@ }, "time": "2026-10-20T10:45:58+00:00" }, + { + "name": "laravel/mcp", + "version": "v1.1.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/mcp.git", + "reference": "e77af70cd009aaa31455626bd463b0e7e39cfcf9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/mcp/zipball/e77af70cd009aaa31455626bd463b0e7e39cfcf9", + "reference": "e77af70cd009aaa31455626bd463b0e7e39cfcf9", + "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-21T11:50:42+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", @@ -2070,6 +2288,79 @@ }, "time": "2026-10-16T14:24:32+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.4", @@ -2259,6 +2550,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", @@ -2578,6 +2928,103 @@ }, "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", + "api", + "auth", + "authorisation", + "authorization", + "oauth", + "oauth 2", + "oauth 2.0", + "oauth2", + "protect", + "resource", + "secure", + "server" + ], + "support": { + "issues": "https://github.com/thephpleague/oauth2-server/issues", + "source": "https://github.com/thephpleague/oauth2-server/tree/9.4.1" + }, + "funding": [ + { + "url": "https://github.com/sephster", + "type": "github" + } + ], + "time": "2026-06-25T15:24:07+00:00" + }, { "name": "league/uri", "version": "7.9.0", @@ -3402,33 +3849,162 @@ "role": "Maintainer" }, { - "name": "Steve 'Sc00bz' Thomas", - "email": "steve@tobtu.com", - "homepage": "https://www.tobtu.com", - "role": "Original Developer" + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" + } + ], + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "keywords": [ + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/constant_time_encoding/issues", + "source": "https://github.com/paragonie/constant_time_encoding" + }, + "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": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations", + "homepage": "http://php-http.org", "keywords": [ - "base16", - "base32", - "base32_decode", - "base32_encode", - "base64", - "base64_decode", - "base64_encode", - "bin2hex", - "encoding", - "hex", - "hex2bin", - "rfc4648" + "adapter", + "client", + "discovery", + "factory", + "http", + "message", + "psr17", + "psr7" ], "support": { - "email": "info@paragonie.com", - "issues": "https://github.com/paragonie/constant_time_encoding/issues", - "source": "https://github.com/paragonie/constant_time_encoding" + "issues": "https://github.com/php-http/discovery/issues", + "source": "https://github.com/php-http/discovery/tree/1.20.0" }, - "time": "2025-09-24T15:06:41+00:00" + "time": "2024-10-02T11:20:13+00:00" }, { "name": "phpoption/phpoption", @@ -3983,6 +4559,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", @@ -6414,6 +7103,93 @@ ], "time": "2026-10-08T16:57:43+00:00" }, + { + "name": "symfony/psr-http-message-bridge", + "version": "v8.1.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/psr-http-message-bridge.git", + "reference": "cfa8f3ac693fec234a91d8745b45ed9a81a0b799" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/cfa8f3ac693fec234a91d8745b45ed9a81a0b799", + "reference": "cfa8f3ac693fec234a91d8745b45ed9a81a0b799", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/http-message": "^1.0|^2.0", + "symfony/http-foundation": "^7.4|^8.0" + }, + "conflict": { + "php-http/discovery": "<1.15" + }, + "require-dev": { + "nyholm/psr7": "^1.1", + "php-http/discovery": "^1.15", + "psr/log": "^1.1.4|^2|^3", + "symfony/browser-kit": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/runtime": "^7.4|^8.0" + }, + "type": "symfony-bridge", + "autoload": { + "psr-4": { + "Symfony\\Bridge\\PsrHttpMessage\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "PSR HTTP message bridge", + "homepage": "https://symfony.com", + "keywords": [ + "http", + "http-message", + "psr-17", + "psr-7" + ], + "support": { + "source": "https://github.com/symfony/psr-http-message-bridge/tree/v8.1.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-07T08:21:34+00:00" + }, { "name": "symfony/routing", "version": "v8.1.9", @@ -9601,81 +10377,6 @@ }, "time": "2026-10-07T19:07:19+00:00" }, - { - "name": "laravel/mcp", - "version": "v1.1.1", - "source": { - "type": "git", - "url": "https://github.com/laravel/mcp.git", - "reference": "e77af70cd009aaa31455626bd463b0e7e39cfcf9" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/laravel/mcp/zipball/e77af70cd009aaa31455626bd463b0e7e39cfcf9", - "reference": "e77af70cd009aaa31455626bd463b0e7e39cfcf9", - "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-21T11:50:42+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..7065ae9a0 --- /dev/null +++ b/database/migrations/2026_10_15_183803_create_oauth_auth_codes_table.php @@ -0,0 +1,22 @@ +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(); + }); + } +}; 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..3a4f71b86 --- /dev/null +++ b/database/migrations/2026_10_15_183804_create_oauth_access_tokens_table.php @@ -0,0 +1,24 @@ +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(); + }); + } +}; 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..0fc5d0f4c --- /dev/null +++ b/database/migrations/2026_10_15_183805_create_oauth_refresh_tokens_table.php @@ -0,0 +1,20 @@ +char('id', 80)->primary(); + $table->char('access_token_id', 80)->index(); + $table->boolean('revoked'); + $table->dateTime('expires_at')->nullable(); + }); + } +}; 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..96e9d4098 --- /dev/null +++ b/database/migrations/2026_10_15_183806_create_oauth_clients_table.php @@ -0,0 +1,25 @@ +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(); + }); + } +}; 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..d03476d82 --- /dev/null +++ b/database/migrations/2026_10_15_183807_create_oauth_device_codes_table.php @@ -0,0 +1,25 @@ +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(); + }); + } +}; diff --git a/resources/js/pages/oauth/authorize.tsx b/resources/js/pages/oauth/authorize.tsx new file mode 100644 index 000000000..d98fbf5a3 --- /dev/null +++ b/resources/js/pages/oauth/authorize.tsx @@ -0,0 +1,85 @@ +import { Head } from '@inertiajs/react'; +import AppLogo from '@/components/app-logo'; +import { Button } from '@/components/ui/button'; +import { useUser } from '@/hooks/use-user'; + +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 } = useUser(); + 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..456ba7b65 --- /dev/null +++ b/tests/Feature/Mcp/DevNullServerTest.php @@ -0,0 +1,238 @@ +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 latest 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' => 'latest']) + ->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' => 'latest']) + ->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('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(); + + 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('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', Commit::MAX_BODY_LENGTH + 1)]) + ->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..fdff93e52 --- /dev/null +++ b/tests/Feature/Mcp/McpAuthenticationTest.php @@ -0,0 +1,151 @@ + 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('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() + ->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('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', + ['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'); +});