feat(mcp): add per-team server toggle (#10771)

This commit is contained in:
Andras Bacsai
2026-07-02 15:19:07 +02:00
committed by GitHub
10 changed files with 83 additions and 2 deletions
+2
View File
@@ -12,6 +12,7 @@ use App\Http\Middleware\CheckForcePasswordReset;
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,
];
}
@@ -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);
}
}
+5
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 @@ class Index extends Component
// 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;
}
}
+2
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()
@@ -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');
});
}
};
@@ -72,7 +72,7 @@
@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)
@@ -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
+1 -1
View File
@@ -4,4 +4,4 @@ use App\Mcp\Servers\CoolifyServer;
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']);
@@ -126,6 +126,18 @@ test('member cannot submit team settings via policy', function () {
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 () {
+10
View File
@@ -90,6 +90,16 @@ test('MCP endpoint returns 404 when the instance setting is disabled', function
$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);