diff --git a/php/Mcp/Console/McpAgentServerCommand.php b/php/Mcp/Console/McpAgentServerCommand.php new file mode 100644 index 0000000..08c726f --- /dev/null +++ b/php/Mcp/Console/McpAgentServerCommand.php @@ -0,0 +1,403 @@ +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; + } +} diff --git a/php/Mcp/Console/McpMonitorCommand.php b/php/Mcp/Console/McpMonitorCommand.php new file mode 100644 index 0000000..b47291c --- /dev/null +++ b/php/Mcp/Console/McpMonitorCommand.php @@ -0,0 +1,494 @@ +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; + } +} diff --git a/php/Mcp/Console/PruneMetricsCommand.php b/php/Mcp/Console/PruneMetricsCommand.php new file mode 100644 index 0000000..d82ec4c --- /dev/null +++ b/php/Mcp/Console/PruneMetricsCommand.php @@ -0,0 +1,80 @@ +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; + } +} diff --git a/php/tests/Feature/Mcp/Console/McpAgentServerCommandTest.php b/php/tests/Feature/Mcp/Console/McpAgentServerCommandTest.php new file mode 100644 index 0000000..5270abe --- /dev/null +++ b/php/tests/Feature/Mcp/Console/McpAgentServerCommandTest.php @@ -0,0 +1,152 @@ +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.'); +}); diff --git a/php/tests/Feature/Mcp/Console/McpMonitorCommandTest.php b/php/tests/Feature/Mcp/Console/McpMonitorCommandTest.php new file mode 100644 index 0000000..4ef93ed --- /dev/null +++ b/php/tests/Feature/Mcp/Console/McpMonitorCommandTest.php @@ -0,0 +1,112 @@ +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); +}); diff --git a/php/tests/Feature/Mcp/Console/PruneMetricsCommandTest.php b/php/tests/Feature/Mcp/Console/PruneMetricsCommandTest.php new file mode 100644 index 0000000..d85db91 --- /dev/null +++ b/php/tests/Feature/Mcp/Console/PruneMetricsCommandTest.php @@ -0,0 +1,88 @@ +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); +});