Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions app/Mcp/CommitResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace App\Mcp;

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
{
public function __construct(private CommitByReferenceQuery $query)
{
//
}

/**
* Commits the user cannot see look like they do not exist.
*/
public function resolve(User $user, string $reference): Commit|Response
{
$commits = $this->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.');
}
}
65 changes: 65 additions & 0 deletions app/Mcp/Presenter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

declare(strict_types=1);

namespace App\Mcp;

use App\Models\Commit;
use App\Models\User;

final readonly class Presenter
{
/**
* @return array<string, mixed>
*/
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<string, mixed>
*/
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);
}
}
39 changes: 39 additions & 0 deletions app/Mcp/Servers/DevNullServer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

namespace App\Mcp\Servers;

use App\Mcp\Tools\CreateCommitStarTool;
use App\Mcp\Tools\CreateCommitTool;
use App\Mcp\Tools\CreateUserFollowTool;
use App\Mcp\Tools\ListUsersTool;
use App\Mcp\Tools\ShowCommitTool;
use App\Mcp\Tools\ShowLogTool;
use Laravel\Mcp\Server;
use Laravel\Mcp\Server\Attributes\Instructions;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Version;

#[Name('devnull')]
#[Version('1.0.0')]
#[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 = [
ShowLogTool::class,
ShowCommitTool::class,
ListUsersTool::class,
CreateCommitTool::class,
CreateCommitStarTool::class,
CreateUserFollowTool::class,
];

protected array $resources = [
//
];

protected array $prompts = [
//
];
}
54 changes: 54 additions & 0 deletions app/Mcp/Tools/CreateCommitStarTool.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

declare(strict_types=1);

namespace App\Mcp\Tools;

use App\Actions\CreateCommitStar;
use App\Mcp\CommitResolver;
use App\Models\Commit;
use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsIdempotent;

#[Description('Star a commit as the signed-in user. Starring twice changes nothing.')]
#[IsIdempotent]
final class CreateCommitStarTool extends Tool
{
public function handle(Request $request, CommitResolver $resolver, CreateCommitStar $action): Response
{
$user = $request->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<string, mixed>
*/
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(),
];
}
}
72 changes: 72 additions & 0 deletions app/Mcp/Tools/CreateCommitTool.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

declare(strict_types=1);

namespace App\Mcp\Tools;

use App\Actions\CreateCommit;
use App\Enums\CommitStatus;
use App\Mcp\Presenter;
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;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;

#[Description('Commit as the signed-in user, or reply to a commit. Commits from non-admins are pending until a moderator approves them, and only the author sees them until then. Limited to 10 commits a minute.')]
final class CreateCommitTool extends Tool
{
private const int MAX_ATTEMPTS = 10;

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:'.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<string, mixed>
*/
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.'),
];
}
}
54 changes: 54 additions & 0 deletions app/Mcp/Tools/CreateUserFollowTool.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

declare(strict_types=1);

namespace App\Mcp\Tools;

use App\Actions\CreateUserFollow;
use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsIdempotent;

#[Description('Follow a user as the signed-in user. Following twice changes nothing.')]
#[IsIdempotent]
final class CreateUserFollowTool extends Tool
{
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'],
]);

$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<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'username' => $schema->string()->description('The username to follow. A leading @ is ignored.')->required(),
];
}
}
48 changes: 48 additions & 0 deletions app/Mcp/Tools/ListUsersTool.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

declare(strict_types=1);

namespace App\Mcp\Tools;

use App\Mcp\Presenter;
use App\Models\User;
use App\Queries\UserSearchQuery;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;

#[Description('Find people on devnull by username or name.')]
#[IsReadOnly]
final class ListUsersTool extends Tool
{
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'],
]);

return Response::structured([
'users' => $query->get($validated['query'], $user)
->map(fn (User $found): array => $presenter->user($found))
->all(),
]);
}

/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'query' => $schema->string()->description('Part of a username or name. A leading @ is ignored.')->required(),
];
}
}
Loading