feat(mcp): add per-team server toggle

This commit is contained in:
Andras Bacsai 2026-06-25 11:42:19 +02:00
parent 3f0a0f7a0d
commit bef94a9ce2
10 changed files with 83 additions and 2 deletions

View file

@ -12,6 +12,7 @@
use App\Http\Middleware\DecideWhatToDoWithUser;
use App\Http\Middleware\EncryptCookies;
use App\Http\Middleware\EnsureMcpEnabled;
use App\Http\Middleware\EnsureTeamMcpEnabled;
use App\Http\Middleware\EnsureTokenBelongsToCurrentTeamMember;
use App\Http\Middleware\PreventRequestsDuringMaintenance;
use App\Http\Middleware\RedirectIfAuthenticated;
@ -110,5 +111,6 @@ class Kernel extends HttpKernel
'can.update.resource' => CanUpdateResource::class,
'can.access.terminal' => CanAccessTerminal::class,
'mcp.enabled' => EnsureMcpEnabled::class,
'mcp.team.enabled' => EnsureTeamMcpEnabled::class,
];
}

View file

@ -0,0 +1,26 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureTeamMcpEnabled
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
$teamId = $user?->currentAccessToken()?->team_id;
$team = $user?->teams()
->where('teams.id', $teamId)
->first();
if (! $team?->is_mcp_server_enabled) {
return response()->json(['message' => 'MCP server is disabled for this team.'], 403);
}
return $next($request);
}
}

View file

@ -24,11 +24,14 @@ class Index extends Component
public ?string $description = null;
public bool $is_mcp_server_enabled = true;
protected function rules(): array
{
return [
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'is_mcp_server_enabled' => 'boolean',
];
}
@ -58,10 +61,12 @@ private function syncData(bool $toModel = false): void
// Sync TO model (before save)
$this->team->name = $this->name;
$this->team->description = $this->description;
$this->team->is_mcp_server_enabled = $this->is_mcp_server_enabled;
} else {
// Sync FROM model (on load/refresh)
$this->name = $this->team->name;
$this->description = $this->team->description;
$this->is_mcp_server_enabled = $this->team->is_mcp_server_enabled;
}
}

View file

@ -47,10 +47,12 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
'personal_team',
'show_boarding',
'custom_server_limit',
'is_mcp_server_enabled',
];
protected $casts = [
'personal_team' => 'boolean',
'is_mcp_server_enabled' => 'boolean',
];
protected static function booted()

View file

@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('teams', function (Blueprint $table) {
$table->boolean('is_mcp_server_enabled')->default(true);
});
}
public function down(): void
{
Schema::table('teams', function (Blueprint $table) {
$table->dropColumn('is_mcp_server_enabled');
});
}
};

View file

@ -72,7 +72,7 @@ class="flex flex-col h-full gap-8 sm:flex-row">
@endif
<h4 class="pt-4">MCP Server</h4>
<div class="md:w-96">
<x-forms.checkbox instantSave id="is_mcp_server_enabled" label="Enable MCP Server"
<x-forms.checkbox instantSave id="is_mcp_server_enabled" label="Enable MCP Server Instance-wide"
helper="Exposes a Streamable HTTP Model Context Protocol endpoint at /mcp for AI clients (Claude Desktop, Cursor, etc.). Authenticates via Sanctum API tokens (Security > API Tokens). Requires API Access to be enabled." />
</div>
@if ($is_mcp_server_enabled)

View file

@ -13,6 +13,8 @@
<div class="flex items-end gap-2 pb-6">
<x-forms.input id="name" label="Name" required canGate="update" :canResource="$team" />
<x-forms.input id="description" label="Description" canGate="update" :canResource="$team" />
<x-forms.checkbox id="is_mcp_server_enabled" label="Enable MCP Server" canGate="update" :canResource="$team"
helper="Allows this team's API tokens to use the instance MCP endpoint when MCP is enabled instance-wide." />
@can('update', $team)
<x-forms.button type="submit">
Save

View file

@ -4,4 +4,4 @@
use Laravel\Mcp\Facades\Mcp;
Mcp::web('/mcp', CoolifyServer::class)
->middleware(['mcp.enabled', 'auth:sanctum', 'api.token.team']);
->middleware(['mcp.enabled', 'auth:sanctum', 'api.token.team', 'mcp.team.enabled']);

View file

@ -126,6 +126,18 @@
expect(auth()->user()->can('update', $this->team))->toBeFalse();
});
test('owner can update team MCP setting', function () {
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
Livewire::test(TeamIndex::class)
->set('is_mcp_server_enabled', false)
->call('submit')
->assertDispatched('success');
expect($this->team->fresh()->is_mcp_server_enabled)->toBeFalse();
});
// --- Team Index Livewire: delete ---
test('member cannot delete team via index', function () {

View file

@ -90,6 +90,16 @@ function expectMcpAuditLog(array $expected): void
$response->assertStatus(404);
});
test('MCP endpoint returns 403 when the token team has MCP disabled', function () {
$this->team->update(['is_mcp_server_enabled' => false]);
$token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
$response = mcpListTools($token);
$response->assertForbidden();
$response->assertJson(['message' => 'MCP server is disabled for this team.']);
});
test('MCP endpoint rejects unauthenticated requests', function () {
$response = mcpPost(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list']);
$response->assertStatus(401);