agent/php/Website/Mcp/Middleware/CheckMcpQuota.php
Snider 8091bad2c0 feat(mcp): implement §4 Middleware (5 middleware classes) (#852)
Additive-only — no existing files modified.

- McpApiKeyAuth: validates Bearer or X-MCP-API-Key header, attaches
  workspace context
- CheckMcpQuota: consumes via McpQuotaService, exposes MCP quota headers
- ValidateWorkspaceContext: normalises + enforces authenticated workspace scope
- ValidateToolDependencies: JSON-RPC + flat tool-call payload validation
  via ToolDependencyService
- McpAuthenticate: combined auth gate chaining the full stack

Pest Feature tests _Good/_Bad/_Ugly per AX-10 for each middleware.
pest skipped (vendor binaries missing in sandbox).

Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=852
2026-04-25 05:25:09 +01:00

83 lines
2.5 KiB
PHP

<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
namespace Core\Mod\Agentic\Website\Mcp\Middleware;
use Closure;
use Core\Mod\Agentic\Mcp\Data\QuotaResult;
use Core\Mod\Agentic\Mcp\Services\McpQuotaService;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class CheckMcpQuota
{
public function __construct(
protected McpQuotaService $quotaService,
) {}
public function handle(Request $request, Closure $next): Response
{
$workspaceId = $this->extractWorkspaceId($request);
if ($workspaceId === null) {
return $next($request);
}
$period = (string) config('mcp.quota_period', 'minute');
$quota = $this->quotaService->checkQuota($workspaceId, $period);
if ($quota->exceeded) {
return response()->json([
'error' => 'quota_exceeded',
'message' => 'MCP workspace quota exceeded.',
'quota' => $quota->toArray(),
], 429, $this->quotaHeaders($quota));
}
$updatedQuota = $this->quotaService->consume($workspaceId, 1, $period);
$request->attributes->set('mcp_quota', $updatedQuota);
$response = $next($request);
foreach ($this->quotaHeaders($updatedQuota) as $header => $value) {
$response->headers->set($header, $value);
}
return $response;
}
protected function extractWorkspaceId(Request $request): int|string|null
{
$workspaceId = $request->attributes->get('workspace_id');
if (is_int($workspaceId) || is_string($workspaceId)) {
return $workspaceId;
}
$context = $request->attributes->get('mcp_workspace_context');
if (is_array($context) && isset($context['workspace_id'])) {
return $context['workspace_id'];
}
$workspace = $request->attributes->get('workspace') ?? $request->attributes->get('mcp_workspace');
if (is_object($workspace) && isset($workspace->id)) {
return $workspace->id;
}
return null;
}
/**
* @return array<string, string>
*/
protected function quotaHeaders(QuotaResult $quota): array
{
return [
'X-MCP-Quota-Limit' => (string) $quota->limit,
'X-MCP-Quota-Used' => (string) $quota->used,
'X-MCP-Quota-Remaining' => (string) $quota->remaining,
'X-MCP-Quota-Period' => $quota->period,
'X-MCP-Quota-Reset-At' => $quota->resetAt->toIso8601String(),
];
}
}