whisper-money/app/Http/Controllers/Settings/McpTokenController.php

117 lines
3.7 KiB
PHP

<?php
namespace App\Http\Controllers\Settings;
use App\Features\Mcp;
use App\Http\Controllers\Controller;
use App\Http\Requests\Settings\StoreMcpTokenRequest;
use Closure;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controllers\HasMiddleware;
use Inertia\Inertia;
use Inertia\Response;
use Laravel\Pennant\Feature;
use Laravel\Sanctum\PersonalAccessToken;
class McpTokenController extends Controller implements HasMiddleware
{
/**
* Hide the whole MCP settings surface behind the rollout feature flag.
*
* @return array<int, Closure>
*/
public static function middleware(): array
{
return [
function (Request $request, Closure $next): mixed {
abort_unless(Feature::active(Mcp::class), 404);
return $next($request);
},
];
}
/**
* Show the MCP access page: existing tokens, connection details and the
* one-time plaintext secret when a token was just created.
*/
public function index(Request $request): Response
{
return Inertia::render('settings/mcp', [
'tokens' => $this->tokensFor($request),
'serverUrl' => url('/mcp'),
'subscribeUrl' => route('subscribe'),
'newToken' => $request->session()->get('mcp_token'),
]);
}
/**
* Create a new MCP token. The plaintext secret is flashed once; only its
* hash is stored, so it can never be shown again. Tokens are read-only for
* now (the `mcp:write` ability is introduced alongside the write tools).
*/
public function store(StoreMcpTokenRequest $request): RedirectResponse
{
$token = $request->user()->createToken($request->validated('name'), ['mcp:read']);
return to_route('mcp.index')->with('mcp_token', $token->plainTextToken);
}
/**
* Revoke (delete) a token the user owns.
*/
public function destroy(Request $request, PersonalAccessToken $token): RedirectResponse
{
$this->authorizeOwnership($request, $token);
$token->delete();
return to_route('mcp.index');
}
/**
* Rotate a token: revoke it and issue a fresh secret keeping the same name
* and scope, so a leaked token can be replaced without reconfiguring intent.
*/
public function rotate(Request $request, PersonalAccessToken $token): RedirectResponse
{
$this->authorizeOwnership($request, $token);
$fresh = $request->user()->createToken($token->name, $token->abilities);
$token->delete();
return to_route('mcp.index')->with('mcp_token', $fresh->plainTextToken);
}
/**
* Ensure the token belongs to the requesting user before mutating it.
*/
private function authorizeOwnership(Request $request, PersonalAccessToken $token): void
{
abort_unless(
$token->tokenable_id === $request->user()->getKey()
&& $token->tokenable_type === $request->user()->getMorphClass(),
403
);
}
/**
* @return list<array{id: int|string, name: string, scope: string, created_at: ?string, last_used_at: ?string}>
*/
private function tokensFor(Request $request): array
{
return $request->user()->tokens()
->latest()
->get()
->map(fn (PersonalAccessToken $token): array => [
'id' => $token->id,
'name' => $token->name,
'scope' => in_array('mcp:write', $token->abilities ?? [], true) ? 'read_write' : 'read',
'created_at' => $token->created_at?->toIso8601String(),
'last_used_at' => $token->last_used_at?->toIso8601String(),
])
->all();
}
}