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:
parent
8091bad2c0
commit
066e1fee51
6 changed files with 1329 additions and 0 deletions
403
php/Mcp/Console/McpAgentServerCommand.php
Normal file
403
php/Mcp/Console/McpAgentServerCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
494
php/Mcp/Console/McpMonitorCommand.php
Normal file
494
php/Mcp/Console/McpMonitorCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
80
php/Mcp/Console/PruneMetricsCommand.php
Normal file
80
php/Mcp/Console/PruneMetricsCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
152
php/tests/Feature/Mcp/Console/McpAgentServerCommandTest.php
Normal file
152
php/tests/Feature/Mcp/Console/McpAgentServerCommandTest.php
Normal 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.');
|
||||
});
|
||||
112
php/tests/Feature/Mcp/Console/McpMonitorCommandTest.php
Normal file
112
php/tests/Feature/Mcp/Console/McpMonitorCommandTest.php
Normal 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);
|
||||
});
|
||||
88
php/tests/Feature/Mcp/Console/PruneMetricsCommandTest.php
Normal file
88
php/tests/Feature/Mcp/Console/PruneMetricsCommandTest.php
Normal 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);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue