Skip to content
Open
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
6 changes: 6 additions & 0 deletions app/Filament/Pages/Settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,12 @@ public function form(Schema $schema): Schema
->live()
->columnSpan(2),

Toggle::make('mcp_users_can_create_items')
->label(trans('settings.mcp.users-can-create-items'))
->helperText(trans('settings.mcp.users-can-create-items-helper-text'))
->visible(fn (Get $get): bool => (bool) $get('enable_mcp'))
->columnSpan(2),

View::make('filament.settings.mcp')
->visible(fn (Get $get): bool => (bool) $get('enable_mcp'))
->columnSpan(2),
Expand Down
3 changes: 3 additions & 0 deletions app/Mcp/Servers/RoadmapServer.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use App\Mcp\Tools\GetItemTool;
use App\Mcp\Tools\MoveItemTool;
use App\Mcp\Tools\ListItemsTool;
use App\Mcp\Tools\CreateItemTool;
use App\Mcp\Tools\GetProjectTool;
use App\Mcp\Tools\ListProjectsTool;
use App\Mcp\Tools\CommentOnItemTool;
Expand All @@ -22,6 +23,7 @@
- Use `list-projects` to discover projects and their boards, and `get-project` for the item counts per board.
- Use `list-items` to browse or search items, and `get-item` to read an item with its comments.
- Use `comment-on-item` to comment on an item or reply to a comment.
- Use `create-item` to submit a new item. Admins and employees can always create items, other users only when an admin allows it.
- Admins and employees can use `move-item` to move an item to another board or project.

Everything happens as the user that owns the API token, so only the projects and items that user can see are available.
Expand All @@ -39,6 +41,7 @@ class RoadmapServer extends Server
ListItemsTool::class,
GetItemTool::class,
CommentOnItemTool::class,
CreateItemTool::class,
MoveItemTool::class,
];

Expand Down
125 changes: 125 additions & 0 deletions app/Mcp/Tools/CreateItemTool.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
<?php

namespace App\Mcp\Tools;

use App\Models\Item;
use App\Models\User;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
use App\Rules\ProfanityCheck;
use App\Settings\GeneralSettings;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Description;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use App\Mcp\Tools\Concerns\InteractsWithRoadmap;

#[Name('create-item')]
#[Description('Create a new roadmap item (feature request, idea or bug), optionally on a board of a project. Available to admins and employees, and to other users when an admin allows it.')]
class CreateItemTool extends Tool
{
use InteractsWithRoadmap;

public function handle(Request $request): Response
{
$user = $this->currentUser();

if (! $this->canCreateItems($user)) {
return Response::error('You are not allowed to create items.');
}

$validated = $request->validate([
'title' => ['required', 'string', 'min:3', 'max:255', new ProfanityCheck()],
'content' => ['required', 'string', 'min:10', new ProfanityCheck()],
'project' => ['nullable'],
'board' => ['nullable'],
]);

$settings = app(GeneralSettings::class);

if ($settings->users_must_verify_email && ! $user->hasVerifiedEmail()) {
return Response::error('You need to verify your email address before you can create items.');
}

$project = null;

if (filled($validated['project'] ?? null)) {
$project = $this->findProject($validated['project']);

if (! $project) {
return Response::error('Project not found.');
}
}

$board = null;

if (filled($validated['board'] ?? null)) {
if (! $project) {
return Response::error('Pass the project the board belongs to.');
}

$board = $this->findBoard($project, $validated['board']);

if (! $board) {
return Response::error("Board not found in project \"{$project->title}\".");
}

if (! $user->hasAdminAccess() && ! $board->canUsersCreateItem()) {
return Response::error("Items can't be created on board \"{$board->title}\".");
}
}

if (! $project && $settings->select_project_when_creating_item && $settings->project_required_when_creating_item) {
return Response::error('A project is required, pass the project to create the item in.');
}

if (! $board && $settings->select_board_when_creating_item && $settings->board_required_when_creating_item) {
return Response::error('A board is required, pass the board to create the item on.');
}

$item = Item::create([
'title' => $validated['title'],
'content' => $validated['content'],
'project_id' => $project?->id,
'board_id' => $board?->id,
]);

$item->user()->associate($user)->save();
$item->toggleUpvote($user);

return Response::json([
'message' => "Created \"{$item->title}\".",
'item' => $this->presentItem($item->refresh()->load(['project', 'board', 'tags'])),
]);
}

/**
* Admins and employees can always create items, other users only when an admin allows it in the MCP settings.
*/
public function shouldRegister(Request $request): bool
{
return $this->canCreateItems($request->user());
}

/**
* @return array<string, \Illuminate\JsonSchema\Types\Type>
*/
public function schema(JsonSchema $schema): array
{
return [
'title' => $schema->string()->min(3)->max(255)->description('The title of the item.')->required(),
'content' => $schema->string()->min(10)->description('The description of the item, markdown is supported.')->required(),
'project' => $schema->string()->description('The ID or slug of the project to create the item in.'),
'board' => $schema->string()->description('The ID or slug of the board to create the item on, requires the project.'),
];
}

private function canCreateItems(?User $user): bool
{
if (! $user) {
return false;
}

return $user->hasAdminAccess() || app(GeneralSettings::class)->mcp_users_can_create_items;
}
}
1 change: 1 addition & 0 deletions app/Settings/GeneralSettings.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ class GeneralSettings extends Settings
public bool $enable_leaderboard;
public int $leaderboard_users_count;
public bool $enable_mcp;
public bool $mcp_users_can_create_items;

public function getInboxWorkflow(): InboxWorkflow
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

use Spatie\LaravelSettings\Migrations\SettingsMigration;

return new class extends SettingsMigration {
public function up(): void
{
$this->migrator->add('general.mcp_users_can_create_items', false);
}
};
2 changes: 1 addition & 1 deletion lang/en/mcp.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
'disabled' => 'The MCP server is disabled, users can\'t see this page or connect. Enable it in the settings under "MCP".',

'permissions-title' => 'What your assistant can do',
'permissions' => 'The assistant acts as you. It sees exactly the projects, items and comments you can see on the roadmap, and comments are posted under your name. Only admins and employees can move items between boards.',
'permissions' => 'The assistant acts as you. It sees exactly the projects, items and comments you can see on the roadmap, and comments and items are posted under your name. Only admins and employees can move items between boards.',

'step-token-title' => '1. Create a token',
'step-token' => 'Create a personal token in your profile. You can revoke it at any time, which disconnects every assistant that uses it.',
Expand Down
2 changes: 2 additions & 0 deletions lang/en/settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@
'mcp' => [
'enable-mcp' => 'Enable MCP server',
'enable-mcp-helper-text' => 'Let users connect AI assistants such as Claude, ChatGPT or Cursor to the roadmap with a personal token.',
'users-can-create-items' => 'Allow users to create items through MCP',
'users-can-create-items-helper-text' => 'Admins and employees can always create items through MCP. Enable this to let other registered users create items with their AI assistant too.',
'how-it-works' => 'How it works',
'how-it-works-description' => 'Users create a personal token under "MCP access" on their profile page and add the roadmap to their AI client. Share the documentation page with your users, it has the same instructions as below.',
'view-docs' => 'Open the documentation page',
Expand Down
13 changes: 13 additions & 0 deletions tests/Feature/Filament/McpSettingsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,16 @@
->assertSeeText([trans('settings.mcp.connect-heading'), 'Claude Desktop', 'ChatGPT'])
->assertSee(route('mcp.docs'));
});

test('admins can allow regular users to create items through mcp', function () {
createAndLoginUser(['role' => UserRole::Admin]);

expect(app(GeneralSettings::class)->mcp_users_can_create_items)->toBeFalse();

Livewire::test(Settings::class)
->fillForm(['enable_mcp' => true, 'mcp_users_can_create_items' => true])
->call('save')
->assertHasNoFormErrors();

expect(app(GeneralSettings::class)->refresh()->mcp_users_can_create_items)->toBeTrue();
});
120 changes: 120 additions & 0 deletions tests/Feature/Mcp/CreateItemToolTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
<?php

use App\Models\Item;
use App\Models\User;
use App\Models\Vote;
use App\Models\Board;
use App\Enums\UserRole;
use App\Models\Project;
use App\Mcp\Tools\CreateItemTool;
use App\Settings\GeneralSettings;
use App\Mcp\Servers\RoadmapServer;
use function Pest\Laravel\assertDatabaseHas;
use function Pest\Laravel\assertDatabaseCount;

it('creates an item on a board as the authenticated user', function (UserRole $role) {
$user = User::factory()->create(['role' => $role]);
$project = Project::factory()->create();
$board = Board::factory()->for($project)->create();

RoadmapServer::actingAs($user)
->tool(CreateItemTool::class, [
'title' => 'Dark mode',
'content' => 'Please add a dark mode to the dashboard.',
'project' => $project->slug,
'board' => $board->slug,
])
->assertOk()
->assertSee('Dark mode');

$item = Item::firstWhere('title', 'Dark mode');

expect($item)
->user_id->toBe($user->id)
->project_id->toBe($project->id)
->board_id->toBe($board->id);

assertDatabaseHas(Vote::class, ['model_id' => $item->id, 'model_type' => Item::class, 'user_id' => $user->id]);
})->with([UserRole::Admin, UserRole::Employee]);

it('creates an item without a project or board', function () {
RoadmapServer::actingAs(User::factory()->admin()->create())
->tool(CreateItemTool::class, ['title' => 'Dark mode', 'content' => 'Please add a dark mode to the dashboard.'])
->assertOk();

assertDatabaseHas(Item::class, ['title' => 'Dark mode', 'project_id' => null, 'board_id' => null]);
});

it('does not let regular users create items by default', function () {
RoadmapServer::actingAs(User::factory()->create(['role' => UserRole::User]))
->tool(CreateItemTool::class, ['title' => 'Dark mode', 'content' => 'Please add a dark mode to the dashboard.'])
->assertHasErrors();

assertDatabaseCount(Item::class, 0);
});

it('lets regular users create items when an admin allows it', function () {
GeneralSettings::fake(['mcp_users_can_create_items' => true]);

$user = User::factory()->create(['role' => UserRole::User]);

RoadmapServer::actingAs($user)
->tool(CreateItemTool::class, ['title' => 'Dark mode', 'content' => 'Please add a dark mode to the dashboard.'])
->assertOk();

assertDatabaseHas(Item::class, ['title' => 'Dark mode', 'user_id' => $user->id]);
});

it('does not let regular users create items on boards that block item creation', function () {
GeneralSettings::fake(['mcp_users_can_create_items' => true]);

$project = Project::factory()->create();
$board = Board::factory()->for($project)->create(['can_users_create' => false]);

RoadmapServer::actingAs(User::factory()->create(['role' => UserRole::User]))
->tool(CreateItemTool::class, [
'title' => 'Dark mode',
'content' => 'Please add a dark mode to the dashboard.',
'project' => $project->id,
'board' => $board->id,
])
->assertHasErrors();

assertDatabaseCount(Item::class, 0);
});

it('does not create an item on a board of another project', function () {
$project = Project::factory()->create();
$otherBoard = Board::factory()->for(Project::factory())->create();

RoadmapServer::actingAs(User::factory()->admin()->create())
->tool(CreateItemTool::class, [
'title' => 'Dark mode',
'content' => 'Please add a dark mode to the dashboard.',
'project' => $project->id,
'board' => $otherBoard->id,
])
->assertHasErrors();

assertDatabaseCount(Item::class, 0);
});

it('requires a project when the settings require one', function () {
GeneralSettings::fake(['select_project_when_creating_item' => true, 'project_required_when_creating_item' => true]);

RoadmapServer::actingAs(User::factory()->admin()->create())
->tool(CreateItemTool::class, ['title' => 'Dark mode', 'content' => 'Please add a dark mode to the dashboard.'])
->assertHasErrors(['A project is required, pass the project to create the item in.']);

assertDatabaseCount(Item::class, 0);
});

it('requires a verified email when the setting is enabled', function () {
GeneralSettings::fake(['users_must_verify_email' => true]);

RoadmapServer::actingAs(User::factory()->admin()->unverified()->create())
->tool(CreateItemTool::class, ['title' => 'Dark mode', 'content' => 'Please add a dark mode to the dashboard.'])
->assertHasErrors(['You need to verify your email address before you can create items.']);

assertDatabaseCount(Item::class, 0);
});
16 changes: 14 additions & 2 deletions tests/Feature/Mcp/McpEndpointTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,19 @@ function listToolsRequest(): array

expect($tools)
->toContain('list-projects', 'get-project', 'list-items', 'get-item', 'comment-on-item')
->not->toContain('move-item');
->not->toContain('move-item', 'create-item');
});

it('lists the create tool for regular users when an admin allows it', function () {
GeneralSettings::fake(['enable_mcp' => true, 'mcp_users_can_create_items' => true]);

$token = User::factory()->create(['role' => UserRole::User])->createToken('MCP')->plainTextToken;

$tools = withToken($token)->postJson('/mcp', listToolsRequest())
->assertOk()
->json('result.tools.*.name');

expect($tools)->toContain('create-item')->not->toContain('move-item');
});

it('lists the move tool for employees', function () {
Expand All @@ -40,7 +52,7 @@ function listToolsRequest(): array
->assertOk()
->json('result.tools.*.name');

expect($tools)->toContain('move-item');
expect($tools)->toContain('move-item', 'create-item');
});

it('is not available when an admin has disabled mcp', function () {
Expand Down
Loading