mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-14 20:17:05 +00:00
feat(mcp): add MCP server with read-only tools for Coolify resources
Add Model Context Protocol server exposing Coolify infrastructure data to AI assistants. Includes tools for listing/fetching servers, projects, applications, databases, and services, scoped to authenticated team tokens. - Add CoolifyServer with 10 read-only tools (list/get for all resource types) - Add BuildsResponse and ResolvesTeam traits for shared tool logic - Add EnsureMcpEnabled middleware guarding /mcp routes - Add enable/disable MCP API endpoints (root-only) - Add is_mcp_server_enabled toggle in instance settings and advanced UI - Add migration for is_mcp_server_enabled column - Add feature tests for MCP endpoints and toggle API - Scrub sensitive keys (passwords, tokens, raw IDs) from all responses
This commit is contained in:
1 parent
f8755261ba
commit
7ab16ad7b5
28 files changed
+1783
-109
No files matched your search
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Once;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::query()->where('id', 0)->delete();
|
||||
InstanceSettings::query()->delete();
|
||||
$settings = new InstanceSettings(['is_mcp_server_enabled' => true]);
|
||||
$settings->id = 0;
|
||||
$settings->save();
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
});
|
||||
|
||||
function mcpPost(array $payload, ?string $token = null)
|
||||
{
|
||||
$headers = [
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => 'application/json, text/event-stream',
|
||||
];
|
||||
if ($token) {
|
||||
$headers['Authorization'] = 'Bearer '.$token;
|
||||
}
|
||||
|
||||
return test()->withHeaders($headers)->postJson('/mcp', $payload);
|
||||
}
|
||||
|
||||
function mcpListTools(string $token)
|
||||
{
|
||||
return mcpPost([
|
||||
'jsonrpc' => '2.0',
|
||||
'id' => 1,
|
||||
'method' => 'tools/list',
|
||||
'params' => (object) [],
|
||||
], $token);
|
||||
}
|
||||
|
||||
function mcpCallTool(string $token, string $name, array $arguments = [])
|
||||
{
|
||||
return mcpPost([
|
||||
'jsonrpc' => '2.0',
|
||||
'id' => 1,
|
||||
'method' => 'tools/call',
|
||||
'params' => [
|
||||
'name' => $name,
|
||||
'arguments' => (object) $arguments,
|
||||
],
|
||||
], $token);
|
||||
}
|
||||
|
||||
function mcpToolJson($response): array
|
||||
{
|
||||
return json_decode($response->json('result.content.0.text'), true);
|
||||
}
|
||||
|
||||
test('MCP endpoint returns 404 when the instance setting is disabled', function () {
|
||||
InstanceSettings::query()->where('id', 0)->update(['is_mcp_server_enabled' => false]);
|
||||
Once::flush();
|
||||
|
||||
$response = mcpPost(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list']);
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
|
||||
test('MCP endpoint rejects unauthenticated requests', function () {
|
||||
$response = mcpPost(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list']);
|
||||
$response->assertStatus(401);
|
||||
});
|
||||
|
||||
test('MCP endpoint lists tools for an authenticated token', function () {
|
||||
$token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
|
||||
|
||||
$response = mcpListTools($token);
|
||||
$response->assertOk();
|
||||
|
||||
$toolNames = collect($response->json('result.tools'))->pluck('name')->all();
|
||||
expect($toolNames)->toContain(
|
||||
'get_infrastructure_overview',
|
||||
'list_servers',
|
||||
'get_server',
|
||||
'list_projects',
|
||||
'list_applications',
|
||||
'get_application',
|
||||
'list_databases',
|
||||
'get_database',
|
||||
'list_services',
|
||||
'get_service',
|
||||
);
|
||||
expect($toolNames)->not->toContain('get_resource_status');
|
||||
});
|
||||
|
||||
test('list_projects returns summary + pagination scoped to the token team', function () {
|
||||
$project = Project::create(['name' => 'Mine', 'team_id' => $this->team->id]);
|
||||
|
||||
$otherTeam = Team::factory()->create();
|
||||
Project::create(['name' => 'Theirs', 'team_id' => $otherTeam->id]);
|
||||
|
||||
$token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
|
||||
|
||||
$response = mcpCallTool($token, 'list_projects');
|
||||
$response->assertOk();
|
||||
|
||||
$body = mcpToolJson($response);
|
||||
|
||||
expect($body)->toHaveKey('data');
|
||||
expect($body)->toHaveKey('_pagination');
|
||||
expect($body['_pagination']['total'])->toBe(1);
|
||||
expect($body['_pagination']['per_page'])->toBe(50);
|
||||
expect($body['_pagination'])->not->toHaveKey('next');
|
||||
|
||||
$uuids = collect($body['data'])->pluck('uuid')->all();
|
||||
$names = collect($body['data'])->pluck('name')->all();
|
||||
expect($uuids)->toContain($project->uuid);
|
||||
expect($names)->not->toContain('Theirs');
|
||||
expect($body['data'][0])->toHaveKeys(['uuid', 'name', 'description']);
|
||||
});
|
||||
|
||||
test('list_projects paginates with per_page cap at 100', function () {
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
Project::create(['name' => "P{$i}", 'team_id' => $this->team->id]);
|
||||
}
|
||||
$token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
|
||||
|
||||
$response = mcpCallTool($token, 'list_projects', ['per_page' => 2, 'page' => 1]);
|
||||
$body = mcpToolJson($response);
|
||||
|
||||
expect($body['_pagination']['total'])->toBe(3);
|
||||
expect($body['_pagination']['total_pages'])->toBe(2);
|
||||
expect($body['_pagination']['next']['args'])->toMatchArray(['page' => 2, 'per_page' => 2]);
|
||||
expect($body['data'])->toHaveCount(2);
|
||||
|
||||
// Verify max cap
|
||||
$capped = mcpCallTool($token, 'list_projects', ['per_page' => 500]);
|
||||
$cappedBody = mcpToolJson($capped);
|
||||
expect($cappedBody['_pagination']['per_page'])->toBe(100);
|
||||
});
|
||||
|
||||
test('get_infrastructure_overview returns counts', function () {
|
||||
Project::create(['name' => 'One', 'team_id' => $this->team->id]);
|
||||
Project::create(['name' => 'Two', 'team_id' => $this->team->id]);
|
||||
|
||||
$token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
|
||||
|
||||
$response = mcpCallTool($token, 'get_infrastructure_overview');
|
||||
$response->assertOk();
|
||||
|
||||
$body = mcpToolJson($response);
|
||||
expect($body)->toHaveKey('data');
|
||||
expect($body['data'])->toHaveKeys(['coolify_version', 'servers', 'projects', 'counts']);
|
||||
expect($body['data']['counts']['projects'])->toBe(2);
|
||||
expect($body['data']['projects'])->toHaveCount(2);
|
||||
expect($body['data']['projects'][0])->toHaveKey('counts');
|
||||
});
|
||||
|
||||
test('get_server scrubs sensitive nested data and exposes connection_timeout', function () {
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
// creating hook auto-generates a sentinel_token; bump connection_timeout
|
||||
// via saveQuietly to avoid triggering restartSentinel.
|
||||
$server->settings->forceFill(['connection_timeout' => 42])->saveQuietly();
|
||||
|
||||
$token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
|
||||
|
||||
$response = mcpCallTool($token, 'get_server', ['uuid' => $server->uuid]);
|
||||
$response->assertOk();
|
||||
|
||||
$body = mcpToolJson($response);
|
||||
$raw = json_encode($body);
|
||||
|
||||
expect($raw)->not->toContain('sentinel_token');
|
||||
expect($raw)->not->toContain('"team_id"');
|
||||
expect($raw)->not->toContain('"private_key_id"');
|
||||
expect($body['data']['connection_timeout'])->toBe(42);
|
||||
expect($body['data']['uuid'])->toBe($server->uuid);
|
||||
});
|
||||
|
||||
test('tool calls fail when the token lacks the read ability', function () {
|
||||
$token = $this->user->createToken('mcp-no-abilities', [])->plainTextToken;
|
||||
|
||||
$response = mcpCallTool($token, 'list_projects');
|
||||
$response->assertOk();
|
||||
|
||||
expect($response->json('result.isError'))->toBeTrue();
|
||||
expect($response->json('result.content.0.text'))->toContain('Missing required permissions');
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::query()->delete();
|
||||
$settings = new InstanceSettings([
|
||||
'is_mcp_server_enabled' => false,
|
||||
'is_api_enabled' => true,
|
||||
]);
|
||||
$settings->id = 0;
|
||||
$settings->save();
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
});
|
||||
|
||||
function makeRootMcpToken(User $user): string
|
||||
{
|
||||
$token = $user->createToken('mcp-root', ['root']);
|
||||
DB::table('personal_access_tokens')
|
||||
->where('id', $token->accessToken->id)
|
||||
->update(['team_id' => '0']);
|
||||
|
||||
return $token->plainTextToken;
|
||||
}
|
||||
|
||||
function makeNonRootMcpToken(User $user, Team $team, array $abilities = ['write']): string
|
||||
{
|
||||
$token = $user->createToken('mcp-write', $abilities);
|
||||
DB::table('personal_access_tokens')
|
||||
->where('id', $token->accessToken->id)
|
||||
->update(['team_id' => (string) $team->id]);
|
||||
|
||||
return $token->plainTextToken;
|
||||
}
|
||||
|
||||
test('GET /api/v1/mcp/enable enables MCP server with root token', function () {
|
||||
$token = makeRootMcpToken($this->user);
|
||||
|
||||
$response = test()->withHeaders([
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
])->getJson('/api/v1/mcp/enable');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJson(['message' => 'MCP server enabled.']);
|
||||
expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeTrue();
|
||||
});
|
||||
|
||||
test('GET /api/v1/mcp/disable disables MCP server with root token', function () {
|
||||
InstanceSettings::query()->where('id', 0)->update(['is_mcp_server_enabled' => true]);
|
||||
$token = makeRootMcpToken($this->user);
|
||||
|
||||
$response = test()->withHeaders([
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
])->getJson('/api/v1/mcp/disable');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJson(['message' => 'MCP server disabled.']);
|
||||
expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeFalse();
|
||||
});
|
||||
|
||||
test('non-root token cannot enable MCP server', function () {
|
||||
$token = makeNonRootMcpToken($this->user, $this->team);
|
||||
|
||||
$response = test()->withHeaders([
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
])->getJson('/api/v1/mcp/enable');
|
||||
|
||||
$response->assertStatus(403);
|
||||
expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeFalse();
|
||||
});
|
||||
|
||||
test('non-root token cannot disable MCP server', function () {
|
||||
InstanceSettings::query()->where('id', 0)->update(['is_mcp_server_enabled' => true]);
|
||||
$token = makeNonRootMcpToken($this->user, $this->team);
|
||||
|
||||
$response = test()->withHeaders([
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
])->getJson('/api/v1/mcp/disable');
|
||||
|
||||
$response->assertStatus(403);
|
||||
expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeTrue();
|
||||
});
|
||||
|
||||
test('unauthenticated request to /api/v1/mcp/enable returns 401', function () {
|
||||
$response = test()->getJson('/api/v1/mcp/enable');
|
||||
$response->assertStatus(401);
|
||||
});
|
||||
|
||||
test('read-only token cannot toggle MCP server (lacks write ability)', function () {
|
||||
$token = makeNonRootMcpToken($this->user, $this->team, ['read']);
|
||||
|
||||
$response = test()->withHeaders([
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
])->getJson('/api/v1/mcp/enable');
|
||||
|
||||
$response->assertStatus(403);
|
||||
});
|
||||
Reference in new issue
Block a user