From 75827174a01fe270f1cc94d90b8862a6e285c3fd Mon Sep 17 00:00:00 2001 From: Sander van Hooft <7265703+sandervanhooft@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:31:19 +0200 Subject: [PATCH] Add create-item MCP tool Admins and employees can always create items through MCP. A new MCP setting (disabled by default) lets other registered users create items too. --- app/Filament/Pages/Settings.php | 6 + app/Mcp/Servers/RoadmapServer.php | 3 + app/Mcp/Tools/CreateItemTool.php | 125 ++++++++++++++++++ app/Settings/GeneralSettings.php | 1 + ...add_mcp_users_can_create_items_setting.php | 10 ++ lang/en/mcp.php | 2 +- lang/en/settings.php | 2 + tests/Feature/Filament/McpSettingsTest.php | 13 ++ tests/Feature/Mcp/CreateItemToolTest.php | 120 +++++++++++++++++ tests/Feature/Mcp/McpEndpointTest.php | 16 ++- 10 files changed, 295 insertions(+), 3 deletions(-) create mode 100644 app/Mcp/Tools/CreateItemTool.php create mode 100644 database/settings/2026_10_05_120000_add_mcp_users_can_create_items_setting.php create mode 100644 tests/Feature/Mcp/CreateItemToolTest.php diff --git a/app/Filament/Pages/Settings.php b/app/Filament/Pages/Settings.php index 89d9d649..2abe87fb 100644 --- a/app/Filament/Pages/Settings.php +++ b/app/Filament/Pages/Settings.php @@ -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), diff --git a/app/Mcp/Servers/RoadmapServer.php b/app/Mcp/Servers/RoadmapServer.php index c6d60123..9ef87601 100644 --- a/app/Mcp/Servers/RoadmapServer.php +++ b/app/Mcp/Servers/RoadmapServer.php @@ -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; @@ -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. @@ -39,6 +41,7 @@ class RoadmapServer extends Server ListItemsTool::class, GetItemTool::class, CommentOnItemTool::class, + CreateItemTool::class, MoveItemTool::class, ]; diff --git a/app/Mcp/Tools/CreateItemTool.php b/app/Mcp/Tools/CreateItemTool.php new file mode 100644 index 00000000..23473bdf --- /dev/null +++ b/app/Mcp/Tools/CreateItemTool.php @@ -0,0 +1,125 @@ +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 + */ + 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; + } +} diff --git a/app/Settings/GeneralSettings.php b/app/Settings/GeneralSettings.php index fe90ef74..51b2ef78 100644 --- a/app/Settings/GeneralSettings.php +++ b/app/Settings/GeneralSettings.php @@ -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 { diff --git a/database/settings/2026_10_05_120000_add_mcp_users_can_create_items_setting.php b/database/settings/2026_10_05_120000_add_mcp_users_can_create_items_setting.php new file mode 100644 index 00000000..365d3e2e --- /dev/null +++ b/database/settings/2026_10_05_120000_add_mcp_users_can_create_items_setting.php @@ -0,0 +1,10 @@ +migrator->add('general.mcp_users_can_create_items', false); + } +}; diff --git a/lang/en/mcp.php b/lang/en/mcp.php index 91fdf4f3..9cc9d5c6 100644 --- a/lang/en/mcp.php +++ b/lang/en/mcp.php @@ -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.', diff --git a/lang/en/settings.php b/lang/en/settings.php index 1bae30a7..1c9d4158 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -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', diff --git a/tests/Feature/Filament/McpSettingsTest.php b/tests/Feature/Filament/McpSettingsTest.php index 7bad57f8..877d62e2 100644 --- a/tests/Feature/Filament/McpSettingsTest.php +++ b/tests/Feature/Filament/McpSettingsTest.php @@ -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(); +}); diff --git a/tests/Feature/Mcp/CreateItemToolTest.php b/tests/Feature/Mcp/CreateItemToolTest.php new file mode 100644 index 00000000..a7ca944e --- /dev/null +++ b/tests/Feature/Mcp/CreateItemToolTest.php @@ -0,0 +1,120 @@ +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); +}); diff --git a/tests/Feature/Mcp/McpEndpointTest.php b/tests/Feature/Mcp/McpEndpointTest.php index 8ddd70d1..6df37603 100644 --- a/tests/Feature/Mcp/McpEndpointTest.php +++ b/tests/Feature/Mcp/McpEndpointTest.php @@ -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 () { @@ -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 () {