feat(mcp): implement §8 Console Commands (3 commands) (#853)

Additive-only — no existing files modified.

- McpAgentServerCommand: line-oriented JSON-RPC stdio loop over
  ToolRegistry with McpQuotaService + QueryAuditService hooks
- PruneMetricsCommand: prunes stale mcp_tool_metrics rows + aggregate
  reporting, fails cleanly when table missing
- McpMonitorCommand: status / alerts / export / report / prometheus
  subcommands, --json flag

Pest Feature tests _Good/_Bad/_Ugly per AX-10 for each command.
Boot.php registration deferred per scope (additive-only). pest skipped
(vendor binaries missing).

Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=853
This commit is contained in:
Snider 2026-04-25 05:27:47 +01:00
parent 8091bad2c0
commit 066e1fee51
6 changed files with 1329 additions and 0 deletions

View file

@ -0,0 +1,403 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Console;
use Core\Mod\Agentic\Mcp\Services\McpQuotaService;
use Core\Mod\Agentic\Mcp\Services\QueryAuditService;
use Core\Mod\Agentic\Mcp\Services\ToolRegistry;
use Illuminate\Console\Command;
use InvalidArgumentException;
use JsonException;
use RuntimeException;
use Throwable;
class McpAgentServerCommand extends Command
{
protected $signature = 'mcp:agent-server';
protected $description = 'Run the MCP agent server in stdio mode';
public function handle(
ToolRegistry $toolRegistry,
McpQuotaService $quotaService,
QueryAuditService $queryAuditService,
): int {
$inputPath = $this->streamPath('MCP_AGENT_SERVER_INPUT', 'php://stdin');
$outputPath = $this->streamPath('MCP_AGENT_SERVER_OUTPUT', 'php://stdout');
$input = @fopen($inputPath, 'rb');
$output = @fopen($outputPath, 'wb');
if (! is_resource($input) || ! is_resource($output)) {
$this->error('Unable to open MCP stdio streams.');
return self::FAILURE;
}
$reachedEof = false;
try {
while (($line = fgets($input)) !== false) {
$payload = trim($line);
if ($payload === '') {
continue;
}
$response = $this->processPayload(
$payload,
$toolRegistry,
$quotaService,
$queryAuditService,
);
if ($response === null) {
continue;
}
fwrite($output, $this->encodePayload($response).PHP_EOL);
fflush($output);
}
$reachedEof = feof($input);
} finally {
if ($inputPath !== 'php://stdin' && is_resource($input)) {
fclose($input);
}
if ($outputPath !== 'php://stdout' && is_resource($output)) {
fclose($output);
}
}
if (! $reachedEof) {
$this->error('MCP stdio stream closed unexpectedly.');
return self::FAILURE;
}
return self::SUCCESS;
}
private function streamPath(string $variable, string $default): string
{
$value = getenv($variable);
return is_string($value) && $value !== '' ? $value : $default;
}
private function processPayload(
string $payload,
ToolRegistry $toolRegistry,
McpQuotaService $quotaService,
QueryAuditService $queryAuditService,
): array|null {
try {
$request = json_decode($payload, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
return $this->errorResponse(-32700, 'Parse error');
}
if (! is_array($request)) {
return $this->errorResponse(-32600, 'Invalid Request');
}
if (array_is_list($request)) {
if ($request === []) {
return $this->errorResponse(-32600, 'Invalid Request');
}
$responses = [];
foreach ($request as $item) {
$response = $this->processRequest(
is_array($item) ? $item : [],
$toolRegistry,
$quotaService,
$queryAuditService,
);
if ($response !== null) {
$responses[] = $response;
}
}
return $responses === [] ? null : $responses;
}
return $this->processRequest(
$request,
$toolRegistry,
$quotaService,
$queryAuditService,
);
}
private function processRequest(
array $request,
ToolRegistry $toolRegistry,
McpQuotaService $quotaService,
QueryAuditService $queryAuditService,
): array|null {
$id = $request['id'] ?? null;
if (($request['jsonrpc'] ?? null) !== '2.0') {
return $this->errorResponse(-32600, 'Invalid Request', $id);
}
$method = $request['method'] ?? null;
if (! is_string($method) || $method === '') {
return $this->errorResponse(-32600, 'Invalid Request', $id);
}
$params = $request['params'] ?? [];
if (! is_array($params)) {
return $this->errorResponse(-32602, 'Invalid params', $id);
}
$response = match ($method) {
'initialize' => $this->successResponse([
'serverInfo' => [
'name' => 'core-agent-mcp',
'transport' => 'stdio',
],
'capabilities' => [
'tools' => [
'list' => true,
'call' => true,
],
],
], $id),
'ping' => $this->successResponse(['ok' => true], $id),
'tools/list' => $this->successResponse([
'tools' => array_map(
static fn ($tool): array => [
'name' => $tool->name,
'description' => $tool->description,
'inputSchema' => $tool->inputSchema,
'dependencies' => $tool->dependencies,
'metadata' => $tool->metadata,
],
$toolRegistry->listTools(),
),
], $id),
'tools/call' => $this->handleToolCall(
$params,
$id,
$toolRegistry,
$quotaService,
$queryAuditService,
),
'resources/list' => $this->successResponse(['resources' => []], $id),
default => $this->errorResponse(-32601, 'Method not found', $id),
};
if (! array_key_exists('id', $request)) {
return null;
}
return $response;
}
private function handleToolCall(
array $params,
mixed $id,
ToolRegistry $toolRegistry,
McpQuotaService $quotaService,
QueryAuditService $queryAuditService,
): array {
$toolName = $this->resolveToolName($params);
if ($toolName === null) {
return $this->errorResponse(-32602, 'Tool name is required.', $id);
}
$arguments = $params['arguments'] ?? $params['input'] ?? [];
if (! is_array($arguments)) {
return $this->errorResponse(-32602, 'Tool arguments must be an array.', $id);
}
$workspaceId = $this->workspaceId($params, $arguments);
$quota = $quotaService->checkQuota($workspaceId);
if ($quota->exceeded) {
return $this->errorResponse(-32001, 'MCP quota exceeded.', $id, [
'quota' => $quota->toArray(),
'workspace_id' => $workspaceId,
]);
}
$query = $this->toolQuery($arguments);
if ($query !== null && ! $queryAuditService->isSafe($query)) {
$this->recordAudit(
$queryAuditService,
$query,
$workspaceId,
$toolName,
0,
['rejected' => true],
);
return $this->errorResponse(-32002, 'Unsafe MCP query rejected.', $id, [
'tool' => $toolName,
'workspace_id' => $workspaceId,
]);
}
$consumedQuota = $quotaService->consume($workspaceId);
$startedAt = microtime(true);
try {
$result = $toolRegistry->call($toolName, $arguments, [
'workspace_id' => $workspaceId,
'request_id' => $id,
'transport' => 'stdio',
]);
$durationMs = (int) round((microtime(true) - $startedAt) * 1000);
if ($query !== null) {
$this->recordAudit(
$queryAuditService,
$query,
$workspaceId,
$toolName,
$durationMs,
['request_id' => $id, 'transport' => 'stdio'],
$this->resultCount($result),
);
}
return $this->successResponse([
'tool' => $toolName,
'result' => $result,
'quota' => $consumedQuota->toArray(),
'duration_ms' => $durationMs,
], $id);
} catch (InvalidArgumentException $exception) {
return $this->errorResponse(-32602, $exception->getMessage(), $id, [
'tool' => $toolName,
]);
} catch (Throwable $exception) {
$durationMs = (int) round((microtime(true) - $startedAt) * 1000);
if ($query !== null) {
$this->recordAudit(
$queryAuditService,
$query,
$workspaceId,
$toolName,
$durationMs,
[
'request_id' => $id,
'transport' => 'stdio',
'error' => $exception->getMessage(),
],
);
}
return $this->errorResponse(-32603, $exception->getMessage(), $id, [
'tool' => $toolName,
'workspace_id' => $workspaceId,
]);
}
}
private function resolveToolName(array $params): string|null
{
$value = $params['name'] ?? $params['tool'] ?? null;
return is_string($value) && $value !== '' ? $value : null;
}
private function workspaceId(array $params, array $arguments): string
{
$value = $params['workspace_id']
?? $params['workspace']
?? $arguments['workspace_id']
?? $arguments['workspace']
?? 'default';
return (string) $value;
}
private function toolQuery(array $arguments): string|null
{
$query = $arguments['query'] ?? null;
return is_string($query) && $query !== '' ? $query : null;
}
private function resultCount(mixed $result): int
{
if (is_array($result)) {
return count($result);
}
return $result === null ? 0 : 1;
}
private function recordAudit(
QueryAuditService $queryAuditService,
string $query,
string $workspaceId,
string $toolName,
int $durationMs,
array $metadata = [],
?int $resultCount = null,
): void {
try {
$queryAuditService->log($query, [
'workspace_id' => $workspaceId,
'tool_name' => $toolName,
'result_count' => $resultCount,
'duration_ms' => $durationMs,
'metadata' => $metadata,
]);
} catch (RuntimeException) {
// The audit table is optional in this worktree.
}
}
private function successResponse(mixed $result, mixed $id): array
{
return [
'jsonrpc' => '2.0',
'result' => $result,
'id' => $id,
];
}
private function errorResponse(int $code, string $message, mixed $id = null, array $data = []): array
{
$error = [
'code' => $code,
'message' => $message,
];
if ($data !== []) {
$error['data'] = $data;
}
return [
'jsonrpc' => '2.0',
'error' => $error,
'id' => $id,
];
}
private function encodePayload(mixed $payload): string
{
$encoded = json_encode(
$payload,
JSON_INVALID_UTF8_SUBSTITUTE | JSON_UNESCAPED_SLASHES,
);
if ($encoded === false) {
return '{"jsonrpc":"2.0","error":{"code":-32603,"message":"Unable to encode response payload."},"id":null}';
}
return $encoded;
}
}

View file

@ -0,0 +1,494 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Console;
use Carbon\CarbonImmutable;
use Core\Mod\Agentic\Mcp\Services\QueryAuditService;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use RuntimeException;
class McpMonitorCommand extends Command
{
private const METRICS_TABLE = 'mcp_tool_metrics';
protected $signature = 'mcp:monitor
{action=status : Action to perform}
{--days=7 : Number of days to include in the report window}
{--json : Output machine-readable JSON}';
protected $description = 'Monitor MCP health, alerts, exports, and metrics output';
public function handle(QueryAuditService $queryAuditService): int
{
$days = $this->days();
if ($days === null) {
return self::FAILURE;
}
$action = strtolower((string) $this->argument('action'));
return match ($action) {
'status' => $this->statusAction($days, $queryAuditService),
'alerts' => $this->alertsAction($days, $queryAuditService),
'export' => $this->exportAction($days, $queryAuditService),
'report' => $this->reportAction($days, $queryAuditService),
'prometheus' => $this->prometheusAction($days),
default => $this->unsupportedAction($action),
};
}
private function statusAction(int $days, QueryAuditService $queryAuditService): int
{
$health = $this->healthStatus($days, $queryAuditService);
if ((bool) $this->option('json')) {
$this->line($this->json([
'action' => 'status',
'days' => $days,
'status' => $health['status'],
'metrics' => $health['metrics'],
'issues' => $health['issues'],
]));
return $health['status'] === 'CRITICAL' ? self::FAILURE : self::SUCCESS;
}
$this->line(sprintf('MCP Health Status: %s', $health['status']));
$this->newLine();
$this->table(['Metric', 'Value'], [
['Total Calls', number_format((int) $health['metrics']['total_calls'])],
['Success Rate', sprintf('%.1f%%', (float) $health['metrics']['success_rate'])],
['Error Rate', sprintf('%.1f%%', (float) $health['metrics']['error_rate'])],
['Avg Duration', sprintf('%dms', (int) $health['metrics']['avg_duration_ms'])],
]);
if ($health['issues'] === []) {
$this->info('No issues detected.');
} else {
$this->line('Issues Detected:');
foreach ($health['issues'] as $issue) {
$this->line(sprintf(' [!] %s', $issue));
}
}
return $health['status'] === 'CRITICAL' ? self::FAILURE : self::SUCCESS;
}
private function alertsAction(int $days, QueryAuditService $queryAuditService): int
{
$alerts = $this->alerts($days, $queryAuditService);
if ((bool) $this->option('json')) {
$this->line($this->json([
'action' => 'alerts',
'days' => $days,
'alerts' => $alerts,
]));
return $alerts === [] ? self::SUCCESS : self::FAILURE;
}
if ($alerts === []) {
$this->info('No MCP alerts detected.');
return self::SUCCESS;
}
$this->line('MCP Alerts:');
foreach ($alerts as $alert) {
$this->line(sprintf(' [!] %s', $alert));
}
return self::FAILURE;
}
private function exportAction(int $days, QueryAuditService $queryAuditService): int
{
$report = $this->summaryReport($days, $queryAuditService);
Log::info('MCP metrics export', [
'days' => $days,
'overview' => $report['overview'],
'top_tools' => $report['top_tools'],
'anomalies' => $report['anomalies'],
]);
if ((bool) $this->option('json')) {
$this->line($this->json([
'action' => 'export',
'days' => $days,
'exported' => true,
'channel' => 'log',
'report' => $report,
]));
return self::SUCCESS;
}
$this->info('Exported MCP metrics summary to the log channel.');
return self::SUCCESS;
}
private function reportAction(int $days, QueryAuditService $queryAuditService): int
{
$report = $this->summaryReport($days, $queryAuditService);
if ((bool) $this->option('json')) {
$this->line($this->json([
'action' => 'report',
'days' => $days,
'report' => $report,
]));
return self::SUCCESS;
}
$this->line(sprintf('MCP Summary Report (%d day window)', $days));
$this->newLine();
$this->table(['Metric', 'Value'], [
['Total Calls', number_format((int) $report['overview']['total_calls'])],
['Success Rate', sprintf('%.1f%%', (float) $report['overview']['success_rate'])],
['Error Rate', sprintf('%.1f%%', (float) $report['overview']['error_rate'])],
['Avg Duration', sprintf('%dms', (int) $report['overview']['avg_duration_ms'])],
]);
if ($report['top_tools'] !== []) {
$this->newLine();
$this->table(['Tool', 'Calls', 'Error Rate', 'Avg Duration'], array_map(
static fn (array $tool): array => [
$tool['tool_id'],
number_format((int) $tool['call_count']),
sprintf('%.1f%%', (float) $tool['error_rate']),
sprintf('%dms', (int) $tool['avg_duration_ms']),
],
$report['top_tools'],
));
}
if ($report['anomalies'] === []) {
$this->info('No anomalies detected.');
return self::SUCCESS;
}
$this->line('Anomalies:');
foreach ($report['anomalies'] as $anomaly) {
$this->line(sprintf(' [!] %s', $anomaly));
}
return self::SUCCESS;
}
private function prometheusAction(int $days): int
{
$metrics = $this->prometheusMetrics($days);
if ((bool) $this->option('json')) {
$this->line($this->json([
'action' => 'prometheus',
'days' => $days,
'metrics' => $metrics,
]));
return self::SUCCESS;
}
$this->output->write($metrics);
return self::SUCCESS;
}
private function unsupportedAction(string $action): int
{
$this->error(sprintf('Unsupported monitor action [%s].', $action));
return self::FAILURE;
}
private function days(): int|null
{
$days = filter_var($this->option('days'), FILTER_VALIDATE_INT);
if ($days === false || $days < 1) {
$this->error('--days must be a positive integer.');
return null;
}
return $days;
}
private function healthStatus(int $days, QueryAuditService $queryAuditService): array
{
$overview = $this->overview($days);
$issues = [];
if (! $overview['metrics_available']) {
$issues[] = 'Metrics table unavailable.';
}
foreach ($this->topTools($days) as $tool) {
if ((float) $tool['error_rate'] > 20.0) {
$issues[] = sprintf('High error rate on tool: %s', $tool['tool_id']);
}
}
$unsafeAudits = $this->unsafeAuditCount($queryAuditService, $days);
if ($unsafeAudits !== null && $unsafeAudits > 0) {
$issues[] = sprintf('%d unsafe query audit entr%s detected.', $unsafeAudits, $unsafeAudits === 1 ? 'y' : 'ies');
}
$status = 'HEALTHY';
if ((float) $overview['error_rate'] > 10.0) {
$status = 'CRITICAL';
} elseif (
(float) $overview['error_rate'] > 5.0
|| (int) $overview['avg_duration_ms'] > 500
|| $issues !== []
) {
$status = 'DEGRADED';
}
return [
'status' => $status,
'metrics' => $overview,
'issues' => $issues,
];
}
private function alerts(int $days, QueryAuditService $queryAuditService): array
{
$alerts = [];
if (! Schema::hasTable(self::METRICS_TABLE)) {
$alerts[] = 'Metrics table unavailable.';
}
foreach ($this->topTools($days) as $tool) {
if ((float) $tool['error_rate'] > 20.0) {
$alerts[] = sprintf(
'Tool [%s] is failing at %.1f%%.',
$tool['tool_id'],
(float) $tool['error_rate'],
);
}
}
$unsafeAudits = $this->unsafeAuditCount($queryAuditService, $days);
if ($unsafeAudits !== null && $unsafeAudits > 0) {
$alerts[] = sprintf('%d unsafe query audit entr%s detected.', $unsafeAudits, $unsafeAudits === 1 ? 'y' : 'ies');
}
return $alerts;
}
private function summaryReport(int $days, QueryAuditService $queryAuditService): array
{
return [
'overview' => $this->overview($days),
'top_tools' => $this->topTools($days),
'anomalies' => $this->anomalies($days, $queryAuditService),
];
}
private function overview(int $days): array
{
$rows = $this->metricRows($days);
if ($rows->isEmpty()) {
return [
'metrics_available' => Schema::hasTable(self::METRICS_TABLE),
'total_calls' => 0,
'success_rate' => 0.0,
'error_rate' => 0.0,
'avg_duration_ms' => 0,
];
}
$totalCalls = (int) $rows->sum(fn (object $row): int => (int) ($row->call_count ?? 0));
$successCount = (int) $rows->sum(fn (object $row): int => (int) ($row->success_count ?? 0));
$errorCount = (int) $rows->sum(fn (object $row): int => (int) ($row->error_count ?? 0));
$weightedDuration = (int) $rows->sum(fn (object $row): int => (int) ($row->avg_duration_ms ?? 0) * (int) ($row->call_count ?? 0));
return [
'metrics_available' => true,
'total_calls' => $totalCalls,
'success_rate' => $totalCalls > 0 ? round(($successCount / $totalCalls) * 100, 1) : 0.0,
'error_rate' => $totalCalls > 0 ? round(($errorCount / $totalCalls) * 100, 1) : 0.0,
'avg_duration_ms' => $totalCalls > 0 ? (int) round($weightedDuration / $totalCalls) : 0,
];
}
private function topTools(int $days): array
{
return $this->metricRows($days)
->groupBy(static fn (object $row): string => (string) ($row->tool_id ?? 'unknown'))
->map(static function (Collection $group, string $toolId): array {
$callCount = (int) $group->sum(fn (object $row): int => (int) ($row->call_count ?? 0));
$errorCount = (int) $group->sum(fn (object $row): int => (int) ($row->error_count ?? 0));
$weightedDuration = (int) $group->sum(
fn (object $row): int => (int) ($row->avg_duration_ms ?? 0) * (int) ($row->call_count ?? 0),
);
return [
'tool_id' => $toolId,
'call_count' => $callCount,
'error_rate' => $callCount > 0 ? round(($errorCount / $callCount) * 100, 1) : 0.0,
'avg_duration_ms' => $callCount > 0 ? (int) round($weightedDuration / $callCount) : 0,
];
})
->sortByDesc('call_count')
->values()
->take(5)
->all();
}
private function anomalies(int $days, QueryAuditService $queryAuditService): array
{
$anomalies = [];
$overview = $this->overview($days);
if ((float) $overview['error_rate'] > 10.0) {
$anomalies[] = sprintf('Overall MCP error rate is %.1f%%.', (float) $overview['error_rate']);
}
if ((int) $overview['avg_duration_ms'] > 500) {
$anomalies[] = sprintf('Average MCP duration is %dms.', (int) $overview['avg_duration_ms']);
}
foreach ($this->topTools($days) as $tool) {
if ((float) $tool['error_rate'] > 20.0) {
$anomalies[] = sprintf(
'Tool [%s] exceeded the 20%% error-rate threshold.',
$tool['tool_id'],
);
}
}
$unsafeAudits = $this->unsafeAuditCount($queryAuditService, $days);
if ($unsafeAudits !== null && $unsafeAudits > 0) {
$anomalies[] = sprintf('%d unsafe query audit entr%s detected.', $unsafeAudits, $unsafeAudits === 1 ? 'y' : 'ies');
}
if (! Schema::hasTable(self::METRICS_TABLE)) {
$anomalies[] = 'Metrics table unavailable.';
}
return $anomalies;
}
private function prometheusMetrics(int $days): string
{
$lines = [
'# HELP mcp_tool_calls_total Total MCP tool calls recorded.',
'# TYPE mcp_tool_calls_total counter',
];
$topTools = $this->topTools($days);
if ($topTools === []) {
$lines[] = 'mcp_tool_calls_total 0';
} else {
foreach ($topTools as $tool) {
$lines[] = sprintf(
'mcp_tool_calls_total{tool="%s"} %d',
$this->prometheusLabel((string) $tool['tool_id']),
(int) $tool['call_count'],
);
}
}
$lines[] = '# HELP mcp_tool_errors_total Total MCP tool errors recorded.';
$lines[] = '# TYPE mcp_tool_errors_total counter';
if ($topTools === []) {
$lines[] = 'mcp_tool_errors_total 0';
} else {
foreach ($topTools as $tool) {
$errorCount = (int) round(((float) $tool['error_rate'] / 100) * (int) $tool['call_count']);
$lines[] = sprintf(
'mcp_tool_errors_total{tool="%s"} %d',
$this->prometheusLabel((string) $tool['tool_id']),
$errorCount,
);
}
}
$lines[] = '# HELP mcp_tool_duration_ms Average MCP tool duration in milliseconds.';
$lines[] = '# TYPE mcp_tool_duration_ms gauge';
if ($topTools === []) {
$lines[] = 'mcp_tool_duration_ms 0';
} else {
foreach ($topTools as $tool) {
$lines[] = sprintf(
'mcp_tool_duration_ms{tool="%s"} %d',
$this->prometheusLabel((string) $tool['tool_id']),
(int) $tool['avg_duration_ms'],
);
}
}
$lines[] = '# HELP mcp_quota_exceeded_total Total MCP quota exceeded events observed by the monitor.';
$lines[] = '# TYPE mcp_quota_exceeded_total counter';
$lines[] = 'mcp_quota_exceeded_total 0';
$lines[] = '# HELP mcp_circuit_breaker_open Number of MCP tools with an open circuit breaker.';
$lines[] = '# TYPE mcp_circuit_breaker_open gauge';
$lines[] = 'mcp_circuit_breaker_open 0';
return implode(PHP_EOL, $lines).PHP_EOL;
}
private function unsafeAuditCount(QueryAuditService $queryAuditService, int $days): int|null
{
try {
return $queryAuditService->query([
'safe' => false,
'from' => CarbonImmutable::now()->subDays($days - 1)->startOfDay(),
'limit' => 100,
])->count();
} catch (RuntimeException) {
return null;
}
}
private function metricRows(int $days): Collection
{
if (! Schema::hasTable(self::METRICS_TABLE)) {
return collect();
}
$fromDate = CarbonImmutable::now()->subDays($days - 1)->startOfDay()->toDateString();
return DB::table(self::METRICS_TABLE)
->where('date', '>=', $fromDate)
->get();
}
private function prometheusLabel(string $value): string
{
return str_replace(['\\', '"'], ['\\\\', '\\"'], $value);
}
private function json(array $payload): string
{
$encoded = json_encode(
$payload,
JSON_INVALID_UTF8_SUBSTITUTE | JSON_UNESCAPED_SLASHES,
);
return $encoded === false ? '{}' : $encoded;
}
}

View file

@ -0,0 +1,80 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Console;
use Carbon\CarbonImmutable;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class PruneMetricsCommand extends Command
{
private const TABLE = 'mcp_tool_metrics';
protected $signature = 'mcp:prune-metrics
{--days=30 : Remove metric rows older than this many days}';
protected $description = 'Prune old MCP tool metrics';
public function handle(): int
{
$days = $this->days();
if ($days === null) {
return self::FAILURE;
}
if (! Schema::hasTable(self::TABLE)) {
$this->error('The mcp_tool_metrics table is required for metric pruning.');
return self::FAILURE;
}
$cutoffDate = CarbonImmutable::now()->subDays($days)->startOfDay()->toDateString();
$staleQuery = DB::table(self::TABLE)->where('date', '<', $cutoffDate);
$staleCount = (clone $staleQuery)->count();
if ($staleCount === 0) {
$this->info(sprintf(
'No MCP metric records older than %d day(s) were found.',
$days,
));
return self::SUCCESS;
}
$summary = (clone $staleQuery)
->selectRaw('tool_id, workspace_id, COUNT(*) as metric_rows, COALESCE(SUM(call_count), 0) as total_calls')
->groupBy('tool_id', 'workspace_id')
->get();
$deleted = $staleQuery->delete();
$bucketCount = $summary->count();
$totalCalls = (int) $summary->sum('total_calls');
$this->info(sprintf(
'Pruned %d MCP metric record(s) older than %d day(s) across %d bucket(s) covering %d call(s).',
$deleted,
$days,
$bucketCount,
$totalCalls,
));
return self::SUCCESS;
}
private function days(): int|null
{
$days = filter_var($this->option('days'), FILTER_VALIDATE_INT);
if ($days === false || $days < 1) {
$this->error('--days must be a positive integer.');
return null;
}
return $days;
}
}

View file

@ -0,0 +1,152 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
use Core\Mod\Agentic\Mcp\Console\McpAgentServerCommand;
use Core\Mod\Agentic\Mcp\Services\McpQuotaService;
use Core\Mod\Agentic\Mcp\Services\ToolRegistry;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
beforeEach(function (): void {
$this->app->make(Kernel::class)->registerCommand(
$this->app->make(McpAgentServerCommand::class),
);
ToolRegistry::registerSingleton($this->app)->register(new class
{
public function name(): string
{
return 'echo_tool';
}
public function description(): string
{
return 'Echoes arguments back to the caller.';
}
public function inputSchema(): array
{
return ['type' => 'object'];
}
public function handle(array $arguments, array $context = []): array
{
return [
'arguments' => $arguments,
'context' => $context,
'value' => $arguments['value'] ?? null,
];
}
});
Schema::dropIfExists('mcp_audit_entries');
Schema::create('mcp_audit_entries', function (Blueprint $table): void {
$table->id();
$table->string('workspace_id')->nullable();
$table->string('tool_name')->nullable();
$table->longText('query_text');
$table->string('query_hash', 64);
$table->boolean('is_safe')->default(true);
$table->unsignedInteger('result_count')->nullable();
$table->unsignedInteger('duration_ms')->nullable();
$table->json('metadata')->nullable();
$table->timestamps();
});
});
afterEach(function (): void {
putenv('MCP_AGENT_SERVER_INPUT');
putenv('MCP_AGENT_SERVER_OUTPUT');
});
function mcpAgentServerRun(string $request): array
{
$inputPath = tempnam(sys_get_temp_dir(), 'mcp-agent-input-');
$outputPath = tempnam(sys_get_temp_dir(), 'mcp-agent-output-');
file_put_contents($inputPath, $request);
putenv(sprintf('MCP_AGENT_SERVER_INPUT=%s', $inputPath));
putenv(sprintf('MCP_AGENT_SERVER_OUTPUT=%s', $outputPath));
$exitCode = Artisan::call('mcp:agent-server');
$lines = file($outputPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
$responses = array_map(
static fn (string $line): array => json_decode($line, true, 512, JSON_THROW_ON_ERROR),
$lines,
);
@unlink($inputPath);
@unlink($outputPath);
return [
'exitCode' => $exitCode,
'responses' => $responses,
];
}
test('McpAgentServerCommand_handle_Good_processes_stdio_tool_calls_and_records_safe_queries', function (): void {
config()->set('mcp.quota_limit', 5);
$result = mcpAgentServerRun(json_encode([
'jsonrpc' => '2.0',
'method' => 'tools/call',
'params' => [
'name' => 'echo_tool',
'arguments' => [
'workspace_id' => 'workspace-good',
'query' => 'select * from agent_plans',
'value' => 'pong',
],
],
'id' => 7,
]).PHP_EOL);
expect($result['exitCode'])->toBe(0)
->and($result['responses'])->toHaveCount(1)
->and($result['responses'][0]['result']['tool'])->toBe('echo_tool')
->and($result['responses'][0]['result']['result']['value'])->toBe('pong')
->and($result['responses'][0]['result']['quota']['used'])->toBe(1)
->and(Schema::hasTable('mcp_audit_entries'))->toBeTrue()
->and(DB::table('mcp_audit_entries')->count())->toBe(1);
});
test('McpAgentServerCommand_handle_Bad_returns_parse_errors_for_invalid_json', function (): void {
$result = mcpAgentServerRun("{bad json\n");
expect($result['exitCode'])->toBe(0)
->and($result['responses'])->toHaveCount(1)
->and($result['responses'][0]['error']['code'])->toBe(-32700)
->and($result['responses'][0]['error']['message'])->toBe('Parse error');
});
test('McpAgentServerCommand_handle_Ugly_rejects_tool_calls_after_quota_is_exhausted', function (): void {
config()->set('mcp.quota_limit', 1);
$quotaService = $this->app->make(McpQuotaService::class);
$quotaService->setQuota('workspace-ugly', 1);
$quotaService->consume('workspace-ugly');
$result = mcpAgentServerRun(json_encode([
'jsonrpc' => '2.0',
'method' => 'tools/call',
'params' => [
'name' => 'echo_tool',
'arguments' => [
'workspace_id' => 'workspace-ugly',
'value' => 'blocked',
],
],
'id' => 8,
]).PHP_EOL);
expect($result['exitCode'])->toBe(0)
->and($result['responses'])->toHaveCount(1)
->and($result['responses'][0]['error']['code'])->toBe(-32001)
->and($result['responses'][0]['error']['message'])->toBe('MCP quota exceeded.');
});

View file

@ -0,0 +1,112 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
use Carbon\CarbonImmutable;
use Core\Mod\Agentic\Mcp\Console\McpMonitorCommand;
use Core\Mod\Agentic\Mcp\Services\QueryAuditService;
use Illuminate\Console\Command;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
beforeEach(function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-04-25 12:00:00'));
$this->app->make(Kernel::class)->registerCommand(
$this->app->make(McpMonitorCommand::class),
);
Schema::dropIfExists('mcp_tool_metrics');
Schema::create('mcp_tool_metrics', function (Blueprint $table): void {
$table->id();
$table->string('tool_id');
$table->string('workspace_id');
$table->date('date');
$table->unsignedInteger('call_count')->default(0);
$table->unsignedInteger('success_count')->default(0);
$table->unsignedInteger('error_count')->default(0);
$table->unsignedInteger('avg_duration_ms')->default(0);
$table->unsignedInteger('max_duration_ms')->default(0);
$table->json('total_calls_by_user')->nullable();
$table->timestamps();
});
Schema::dropIfExists('mcp_audit_entries');
Schema::create('mcp_audit_entries', function (Blueprint $table): void {
$table->id();
$table->string('workspace_id')->nullable();
$table->string('tool_name')->nullable();
$table->longText('query_text');
$table->string('query_hash', 64);
$table->boolean('is_safe')->default(true);
$table->unsignedInteger('result_count')->nullable();
$table->unsignedInteger('duration_ms')->nullable();
$table->json('metadata')->nullable();
$table->timestamps();
});
});
afterEach(function (): void {
CarbonImmutable::setTestNow();
});
function mcpMonitorMetric(string $toolId, string $date, int $callCount, int $successCount, int $errorCount, int $avgDurationMs): void
{
DB::table('mcp_tool_metrics')->insert([
'tool_id' => $toolId,
'workspace_id' => 'workspace-1',
'date' => $date,
'call_count' => $callCount,
'success_count' => $successCount,
'error_count' => $errorCount,
'avg_duration_ms' => $avgDurationMs,
'max_duration_ms' => $avgDurationMs + 25,
'total_calls_by_user' => json_encode(['virgil' => $callCount]),
'created_at' => now(),
'updated_at' => now(),
]);
}
test('McpMonitorCommand_handle_Good_outputs_a_machine_readable_summary_report', function (): void {
mcpMonitorMetric('session_start', '2026-04-24', 10, 9, 1, 120);
mcpMonitorMetric('report_generate', '2026-04-25', 5, 5, 0, 180);
$exitCode = Artisan::call('mcp:monitor', [
'action' => 'report',
'--days' => 7,
'--json' => true,
]);
$output = json_decode(Artisan::output(), true, 512, JSON_THROW_ON_ERROR);
expect($exitCode)->toBe(Command::SUCCESS)
->and($output['action'])->toBe('report')
->and($output['report']['overview']['total_calls'])->toBe(15)
->and($output['report']['top_tools'][0]['tool_id'])->toBe('session_start');
});
test('McpMonitorCommand_handle_Bad_rejects_unknown_actions', function (): void {
$this->artisan('mcp:monitor', ['action' => 'invalid'])
->expectsOutput('Unsupported monitor action [invalid].')
->assertExitCode(Command::FAILURE);
});
test('McpMonitorCommand_handle_Ugly_returns_failure_when_status_is_critical', function (): void {
mcpMonitorMetric('send_email', '2026-04-25', 10, 8, 2, 140);
mcpMonitorMetric('send_email', '2026-04-24', 10, 8, 2, 160);
app(QueryAuditService::class)->log('select * from agent_plans', [
'workspace_id' => 'workspace-1',
'tool_name' => 'plan_list',
'duration_ms' => 20,
'result_count' => 2,
]);
$this->artisan('mcp:monitor', ['action' => 'status'])
->expectsOutputToContain('MCP Health Status: CRITICAL')
->assertExitCode(Command::FAILURE);
});

View file

@ -0,0 +1,88 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
use Carbon\CarbonImmutable;
use Core\Mod\Agentic\Mcp\Console\PruneMetricsCommand;
use Illuminate\Console\Command;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
beforeEach(function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-04-25 12:00:00'));
$this->app->make(Kernel::class)->registerCommand(
$this->app->make(PruneMetricsCommand::class),
);
Schema::dropIfExists('mcp_tool_metrics');
Schema::create('mcp_tool_metrics', function (Blueprint $table): void {
$table->id();
$table->string('tool_id');
$table->string('workspace_id');
$table->date('date');
$table->unsignedInteger('call_count')->default(0);
$table->unsignedInteger('success_count')->default(0);
$table->unsignedInteger('error_count')->default(0);
$table->unsignedInteger('avg_duration_ms')->default(0);
$table->unsignedInteger('max_duration_ms')->default(0);
$table->json('total_calls_by_user')->nullable();
$table->timestamps();
});
});
afterEach(function (): void {
CarbonImmutable::setTestNow();
});
function mcpMetricRow(string $toolId, string $workspaceId, string $date, int $callCount): void
{
DB::table('mcp_tool_metrics')->insert([
'tool_id' => $toolId,
'workspace_id' => $workspaceId,
'date' => $date,
'call_count' => $callCount,
'success_count' => max($callCount - 1, 0),
'error_count' => min($callCount, 1),
'avg_duration_ms' => 120,
'max_duration_ms' => 200,
'total_calls_by_user' => json_encode(['virgil' => $callCount]),
'created_at' => now(),
'updated_at' => now(),
]);
}
test('PruneMetricsCommand_handle_Good_prunes_stale_metric_rows_and_reports_the_aggregate', function (): void {
mcpMetricRow('session_start', 'workspace-1', '2026-03-01', 4);
mcpMetricRow('session_start', 'workspace-1', '2026-03-10', 6);
mcpMetricRow('session_start', 'workspace-1', '2026-04-20', 9);
$this->artisan('mcp:prune-metrics', ['--days' => 30])
->expectsOutput('Pruned 2 MCP metric record(s) older than 30 day(s) across 1 bucket(s) covering 10 call(s).')
->assertSuccessful();
expect(DB::table('mcp_tool_metrics')->count())->toBe(1)
->and(DB::table('mcp_tool_metrics')->value('date'))->toBe('2026-04-20');
});
test('PruneMetricsCommand_handle_Bad_rejects_non_positive_retention_windows', function (): void {
mcpMetricRow('session_start', 'workspace-1', '2026-03-01', 4);
$this->artisan('mcp:prune-metrics', ['--days' => 0])
->expectsOutput('--days must be a positive integer.')
->assertExitCode(Command::FAILURE);
expect(DB::table('mcp_tool_metrics')->count())->toBe(1);
});
test('PruneMetricsCommand_handle_Ugly_fails_cleanly_when_the_metrics_table_is_missing', function (): void {
Schema::dropIfExists('mcp_tool_metrics');
$this->artisan('mcp:prune-metrics')
->expectsOutput('The mcp_tool_metrics table is required for metric pruning.')
->assertExitCode(Command::FAILURE);
});