diff --git a/php/Mcp/Resources/AppConfig.php b/php/Mcp/Resources/AppConfig.php new file mode 100644 index 0000000..f514347 --- /dev/null +++ b/php/Mcp/Resources/AppConfig.php @@ -0,0 +1,26 @@ + config('app.name'), + 'env' => config('app.env'), + 'debug' => config('app.debug'), + 'url' => config('app.url'), + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + } +} diff --git a/php/Mcp/Resources/ContentResource.php b/php/Mcp/Resources/ContentResource.php new file mode 100644 index 0000000..5bd3312 --- /dev/null +++ b/php/Mcp/Resources/ContentResource.php @@ -0,0 +1,222 @@ +get('uri', ''); + $parts = $this->parseUri($uri); + + if ($parts === null) { + return Response::text('Invalid URI format. Expected: content://{workspace}/{slug}'); + } + + [$workspaceIdentifier, $contentIdentifier] = $parts; + $workspace = $this->resolveWorkspace($workspaceIdentifier); + + if ($workspace === null) { + return Response::text(sprintf('Workspace not found: %s', $workspaceIdentifier)); + } + + $item = $this->resolveContentItem($workspace, $contentIdentifier); + + if ($item === null) { + return Response::text(sprintf('Content not found: %s', $contentIdentifier)); + } + + return Response::text($this->contentToMarkdown($item, $workspace)); + } + + public static function list(): array + { + return (new self)->listResources(); + } + + protected function listResources(): array + { + if (! class_exists(Workspace::class) || ! class_exists(ContentItem::class)) { + return []; + } + + $resources = []; + + foreach (Workspace::query()->get(['id', 'slug']) as $workspace) { + foreach ($this->publishedItemsForWorkspace($workspace)->take(50) as $item) { + $resources[] = [ + 'uri' => sprintf('content://%s/%s', $workspace->slug, $item->slug), + 'name' => (string) $item->title, + 'description' => sprintf('%s: %s', ucfirst((string) ($item->type ?? 'content')), (string) $item->title), + 'mimeType' => 'text/markdown', + ]; + } + } + + return $resources; + } + + protected function parseUri(string $uri): ?array + { + if (! str_starts_with($uri, 'content://')) { + return null; + } + + $parts = explode('/', substr($uri, 10), 2); + + return count($parts) === 2 ? $parts : null; + } + + protected function resolveWorkspace(string $identifier): ?Workspace + { + if (! class_exists(Workspace::class)) { + return null; + } + + return Workspace::query() + ->where('slug', $identifier) + ->orWhere('id', $identifier) + ->first(); + } + + protected function resolveContentItem(object $workspace, string $identifier): ?object + { + if (! class_exists(ContentItem::class)) { + return null; + } + + $query = ContentItem::query(); + + if (method_exists($query->getModel(), 'scopeForWorkspace')) { + $query->forWorkspace($workspace->id); + } else { + $query->where('workspace_id', $workspace->id); + } + + if (method_exists($query->getModel(), 'scopeNative')) { + $query->native(); + } + + $item = (clone $query)->where('slug', $identifier)->first(); + + if ($item === null && is_numeric($identifier)) { + $item = (clone $query)->find((int) $identifier); + } + + if ($item !== null && method_exists($item, 'loadMissing')) { + $item->loadMissing(['author', 'categories', 'tags', 'taxonomies']); + } + + return $item; + } + + protected function publishedItemsForWorkspace(object $workspace) + { + $query = ContentItem::query(); + + if (method_exists($query->getModel(), 'scopeForWorkspace')) { + $query->forWorkspace($workspace->id); + } else { + $query->where('workspace_id', $workspace->id); + } + + if (method_exists($query->getModel(), 'scopeNative')) { + $query->native(); + } + + if (method_exists($query->getModel(), 'scopePublished')) { + $query->published(); + } else { + $query->where('status', 'publish'); + } + + return $query + ->orderByDesc('updated_at') + ->limit(50) + ->get(['id', 'slug', 'title', 'type']); + } + + protected function contentToMarkdown(object $item, object $workspace): string + { + $frontMatter = [ + 'title' => (string) ($item->title ?? ''), + 'slug' => (string) ($item->slug ?? ''), + 'workspace' => (string) ($workspace->slug ?? $workspace->id ?? ''), + 'type' => (string) ($item->type ?? ''), + 'status' => (string) ($item->status ?? ''), + 'author' => data_get($item, 'author.name'), + 'categories' => $this->taxonomyNames($item, 'categories', 'category'), + 'tags' => $this->taxonomyNames($item, 'tags', 'tag'), + 'publish_at' => $item->publish_at?->toIso8601String(), + 'created_at' => $item->created_at?->toIso8601String(), + 'updated_at' => $item->updated_at?->toIso8601String(), + 'seo_title' => data_get($item, 'seo_meta.title') ?? data_get($item, 'seo_title'), + 'seo_description' => data_get($item, 'seo_meta.description') ?? data_get($item, 'seo_description'), + ]; + + $frontMatter = array_filter($frontMatter, static fn (mixed $value): bool => $value !== null && $value !== []); + + $markdown = "---\n".Yaml::dump($frontMatter, 3, 2)."---\n\n"; + $excerpt = trim((string) ($item->excerpt ?? '')); + + if ($excerpt !== '') { + $markdown .= collect(preg_split('/\R/', $excerpt) ?: []) + ->map(static fn (string $line): string => '> '.$line) + ->implode("\n")."\n\n"; + } + + $markdown .= $this->contentBody($item); + + return $markdown; + } + + protected function taxonomyNames(object $item, string $relation, string $fallbackType): array + { + $names = collect(data_get($item, $relation, [])) + ->map(static fn (mixed $taxonomy): ?string => is_object($taxonomy) ? ($taxonomy->name ?? null) : null) + ->filter() + ->values() + ->all(); + + if ($names !== []) { + return $names; + } + + return collect(data_get($item, 'taxonomies', [])) + ->filter(static fn (mixed $taxonomy): bool => is_object($taxonomy) && (($taxonomy->type ?? null) === $fallbackType || ($taxonomy->taxonomy ?? null) === $fallbackType)) + ->map(static fn (object $taxonomy): ?string => $taxonomy->name ?? null) + ->filter() + ->values() + ->all(); + } + + protected function contentBody(object $item): string + { + $markdown = trim((string) ($item->content_markdown ?? '')); + + if ($markdown !== '') { + return $markdown; + } + + $cleanHtml = trim((string) ($item->content_html_clean ?? '')); + + if ($cleanHtml !== '') { + return trim(strip_tags($cleanHtml)); + } + + return trim(strip_tags((string) ($item->content_html_original ?? ''))); + } +} diff --git a/php/Mcp/Resources/DatabaseSchema.php b/php/Mcp/Resources/DatabaseSchema.php new file mode 100644 index 0000000..478c160 --- /dev/null +++ b/php/Mcp/Resources/DatabaseSchema.php @@ -0,0 +1,54 @@ +tables() as $tableName) { + $schema[$tableName] = $this->describeTable($tableName); + } + + return Response::text((string) json_encode($schema, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + } + + protected function tables(): array + { + try { + return collect(DB::select('SHOW TABLES')) + ->map(static fn (object $row): string => (string) array_values((array) $row)[0]) + ->all(); + } catch (\Throwable) { + return Schema::getTableListing(); + } + } + + protected function describeTable(string $tableName): array + { + $driver = DB::getDriverName(); + + try { + return array_map(static fn (object $column): array => (array) $column, DB::select(sprintf( + $driver === 'sqlite' ? 'PRAGMA table_info("%s")' : 'DESCRIBE `%s`', + $tableName, + ))); + } catch (\Throwable) { + return []; + } + } +} diff --git a/php/Mcp/Services/CircuitBreaker.php b/php/Mcp/Services/CircuitBreaker.php new file mode 100644 index 0000000..e4ebe2f --- /dev/null +++ b/php/Mcp/Services/CircuitBreaker.php @@ -0,0 +1,314 @@ +getState($service); + + if ($state === self::STATE_OPEN) { + if ($fallback !== null) { + return $fallback(); + } + + throw new CircuitOpenException($service); + } + + $hasTrialLock = false; + + if ($state === self::STATE_HALF_OPEN) { + $hasTrialLock = $this->acquireTrialLock($service); + + if (! $hasTrialLock) { + if ($fallback !== null) { + return $fallback(); + } + + throw new CircuitOpenException( + $service, + sprintf("Service '%s' is being tested. Please try again shortly.", $service), + ); + } + } + + try { + $result = $operation(); + + $this->recordSuccess($service); + + return $result; + } catch (Throwable $throwable) { + $this->recordFailure($service, $throwable); + + if ($this->shouldTrip($service)) { + $this->tripCircuit($service); + } + + if ($fallback !== null && $this->isRecoverableError($throwable)) { + return $fallback(); + } + + throw $throwable; + } finally { + if ($hasTrialLock) { + $this->releaseTrialLock($service); + } + } + } + + public function getState(string $service): string + { + $state = Cache::get($this->stateKey($service)); + + if (! is_string($state) || $state === '') { + return self::STATE_CLOSED; + } + + if ($state === self::STATE_OPEN) { + $openedAt = (int) Cache::get($this->openedAtKey($service), 0); + + if ($openedAt > 0 && (time() - $openedAt) >= $this->resetTimeout($service)) { + $this->setState($service, self::STATE_HALF_OPEN); + + return self::STATE_HALF_OPEN; + } + } + + return $state; + } + + public function getStats(string $service): array + { + return [ + 'service' => $service, + 'state' => $this->getState($service), + 'failures' => (int) Cache::get($this->failureCountKey($service), 0), + 'successes' => (int) Cache::get($this->successCountKey($service), 0), + 'last_failure' => Cache::get($this->lastFailureKey($service)), + 'opened_at' => Cache::get($this->openedAtKey($service)), + 'threshold' => $this->failureThreshold($service), + 'reset_timeout' => $this->resetTimeout($service), + ]; + } + + public function reset(string $service): void + { + $this->setState($service, self::STATE_CLOSED); + Cache::forget($this->failureCountKey($service)); + Cache::forget($this->successCountKey($service)); + Cache::forget($this->lastFailureKey($service)); + Cache::forget($this->openedAtKey($service)); + Cache::forget($this->trialLockKey($service)); + } + + public function isAvailable(string $service): bool + { + return $this->getState($service) !== self::STATE_OPEN; + } + + protected function recordSuccess(string $service): void + { + $state = $this->getState($service); + + $this->atomicIncrement($this->successCountKey($service), self::COUNTER_TTL); + + if ($state === self::STATE_HALF_OPEN) { + $this->closeCircuit($service); + } + + $this->atomicDecrement($this->failureCountKey($service)); + } + + protected function recordFailure(string $service, Throwable $throwable): void + { + $window = $this->failureWindow($service); + $failures = $this->atomicIncrement($this->failureCountKey($service), $window); + + Cache::put($this->lastFailureKey($service), [ + 'message' => $throwable->getMessage(), + 'class' => $throwable::class, + 'time' => now()->toIso8601String(), + 'failures' => $failures, + ], $window); + } + + protected function shouldTrip(string $service): bool + { + return (int) Cache::get($this->failureCountKey($service), 0) >= $this->failureThreshold($service); + } + + protected function tripCircuit(string $service): void + { + $this->setState($service, self::STATE_OPEN); + Cache::put($this->openedAtKey($service), time(), 86400); + } + + protected function closeCircuit(string $service): void + { + $this->setState($service, self::STATE_CLOSED); + Cache::forget($this->failureCountKey($service)); + Cache::forget($this->openedAtKey($service)); + Cache::forget($this->trialLockKey($service)); + } + + protected function setState(string $service, string $state): void + { + Cache::put($this->stateKey($service), $state, 86400); + } + + protected function isRecoverableError(Throwable $throwable): bool + { + $patterns = [ + 'SQLSTATE', + 'Connection refused', + 'Table .* doesn\'t exist', + 'Base table or view not found', + 'Connection timed out', + 'Too many connections', + ]; + + foreach ($patterns as $pattern) { + if (preg_match('/'.$pattern.'/i', $throwable->getMessage()) === 1) { + return true; + } + } + + return false; + } + + protected function failureThreshold(string $service): int + { + return (int) config( + sprintf('mcp.circuit_breaker.%s.threshold', $service), + config('mcp.circuit_breaker.default_threshold', 5), + ); + } + + protected function resetTimeout(string $service): int + { + return (int) config( + sprintf('mcp.circuit_breaker.%s.reset_timeout', $service), + config('mcp.circuit_breaker.default_reset_timeout', 60), + ); + } + + protected function failureWindow(string $service): int + { + return (int) config( + sprintf('mcp.circuit_breaker.%s.failure_window', $service), + config('mcp.circuit_breaker.default_failure_window', 120), + ); + } + + protected function atomicIncrement(string $key, int $ttl): int + { + $lock = Cache::lock($key.':lock', 5); + + try { + $lock->block(3); + + $value = (int) Cache::get($key, 0) + 1; + Cache::put($key, $value, $ttl); + + return $value; + } finally { + rescue(static fn (): mixed => $lock->release(), report: false); + } + } + + protected function atomicDecrement(string $key): int + { + $lock = Cache::lock($key.':lock', 5); + + try { + $lock->block(3); + + $value = max((int) Cache::get($key, 0) - 1, 0); + Cache::put($key, $value, self::COUNTER_TTL); + + return $value; + } finally { + rescue(static fn (): mixed => $lock->release(), report: false); + } + } + + protected function acquireTrialLock(string $service): bool + { + return Cache::add($this->trialLockKey($service), true, 30); + } + + protected function releaseTrialLock(string $service): void + { + Cache::forget($this->trialLockKey($service)); + } + + protected function stateKey(string $service): string + { + return self::CACHE_PREFIX.$service.':state'; + } + + protected function failureCountKey(string $service): string + { + return self::CACHE_PREFIX.$service.':failures'; + } + + protected function successCountKey(string $service): string + { + return self::CACHE_PREFIX.$service.':successes'; + } + + protected function lastFailureKey(string $service): string + { + return self::CACHE_PREFIX.$service.':last_failure'; + } + + protected function openedAtKey(string $service): string + { + return self::CACHE_PREFIX.$service.':opened_at'; + } + + protected function trialLockKey(string $service): string + { + return self::CACHE_PREFIX.$service.':trial_lock'; + } + } +} + +namespace Core\Mcp\Exceptions { + + use RuntimeException; + + final class CircuitOpenException extends RuntimeException + { + public function __construct( + public readonly string $service, + string $message = '', + ) { + parent::__construct($message !== '' ? $message : sprintf( + "Service '%s' is temporarily unavailable. Please try again later.", + $service, + )); + } + } +} diff --git a/php/Mcp/Services/DataRedactor.php b/php/Mcp/Services/DataRedactor.php new file mode 100644 index 0000000..44646b3 --- /dev/null +++ b/php/Mcp/Services/DataRedactor.php @@ -0,0 +1,215 @@ +redactArray($data, $maxDepth - 1); + } + + if (is_string($data)) { + return $this->redactString($data); + } + + return $data; + } + + public function summarize(mixed $data, int $maxDepth = 3): mixed + { + if ($maxDepth <= 0) { + return '[...]'; + } + + if (is_array($data)) { + $result = []; + $count = count($data); + $limit = 10; + $items = array_slice($data, 0, $limit, true); + + foreach ($items as $key => $value) { + $lowerKey = strtolower((string) $key); + + if ($this->isSensitiveKey($lowerKey)) { + $result[$key] = self::REDACTED; + + continue; + } + + if ($this->isPiiKey($lowerKey) && is_string($value)) { + $result[$key] = $this->partialRedact($value); + + continue; + } + + $result[$key] = $this->summarize($value, $maxDepth - 1); + } + + if ($count > $limit) { + $result['_truncated'] = sprintf('... and %d more items', $count - $limit); + } + + return $result; + } + + if (is_string($data)) { + $redacted = $this->redactString($data); + + return strlen($redacted) > 100 + ? substr($redacted, 0, 97).'...' + : $redacted; + } + + return $data; + } + + protected function redactArray(array $data, int $maxDepth): array + { + $result = []; + + foreach ($data as $key => $value) { + $lowerKey = strtolower((string) $key); + + if ($this->isSensitiveKey($lowerKey)) { + $result[$key] = self::REDACTED; + + continue; + } + + if ($this->isPiiKey($lowerKey) && is_string($value)) { + $result[$key] = $this->partialRedact($value); + + continue; + } + + if (is_array($value)) { + $result[$key] = $maxDepth <= 0 + ? '[MAX_DEPTH_EXCEEDED]' + : $this->redactArray($value, $maxDepth - 1); + + continue; + } + + $result[$key] = is_string($value) + ? $this->redactString($value) + : $value; + } + + return $result; + } + + protected function isSensitiveKey(string $key): bool + { + foreach (self::SENSITIVE_KEYS as $sensitiveKey) { + if (str_contains($key, $sensitiveKey)) { + return true; + } + } + + return false; + } + + protected function isPiiKey(string $key): bool + { + foreach (self::PII_KEYS as $piiKey) { + if (str_contains($key, $piiKey)) { + return true; + } + } + + return false; + } + + protected function redactString(string $value): string + { + $value = preg_replace('/Bearer\s+[A-Za-z0-9\-_\.]+/i', 'Bearer '.self::REDACTED, $value) ?? $value; + $value = preg_replace('/Basic\s+[A-Za-z0-9+\/=]+/i', 'Basic '.self::REDACTED, $value) ?? $value; + $value = preg_replace('/\b(sk|pk|key|api|token)_[A-Za-z0-9]{16,}\b/i', '$1_'.self::REDACTED, $value) ?? $value; + $value = preg_replace('/eyJ[A-Za-z0-9_-]*\.eyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]*/i', self::REDACTED, $value) ?? $value; + $value = preg_replace('/[A-Z]{2}\s?\d{2}\s?\d{2}\s?\d{2}\s?[A-Z]/i', self::REDACTED, $value) ?? $value; + $value = preg_replace('/\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/', self::REDACTED, $value) ?? $value; + + return $value; + } + + protected function partialRedact(string $value): string + { + $length = strlen($value); + + if ($length <= 4) { + return self::REDACTED; + } + + if ($length <= 8) { + return substr($value, 0, 2).'***'.substr($value, -1); + } + + $visible = min(3, (int) floor($length / 4)); + + return substr($value, 0, $visible).'***'.substr($value, -$visible); + } +} diff --git a/php/Mcp/Services/McpHealthService.php b/php/Mcp/Services/McpHealthService.php new file mode 100644 index 0000000..4799520 --- /dev/null +++ b/php/Mcp/Services/McpHealthService.php @@ -0,0 +1,271 @@ +loadServerConfig($serverId); + + if ($server === null) { + $result = $this->buildResult(self::STATUS_UNKNOWN, 'Server not found'); + Cache::put($cacheKey, $result, $this->cacheTtl); + + return $result; + } + + $result = $this->pingServer($server); + Cache::put($cacheKey, $result, $this->cacheTtl); + + return $result; + } + + public function checkAll(bool $forceRefresh = false): array + { + $results = []; + + foreach ($this->registeredServers() as $serverId) { + $results[$serverId] = $this->check($serverId, $forceRefresh); + } + + return $results; + } + + public function getCachedStatus(string $serverId): ?array + { + $status = Cache::get(sprintf('mcp:health:%s', $serverId)); + + return is_array($status) ? $status : null; + } + + public function clearCache(string $serverId): void + { + Cache::forget(sprintf('mcp:health:%s', $serverId)); + } + + public function clearAllCache(): void + { + foreach ($this->registeredServers() as $serverId) { + $this->clearCache($serverId); + } + } + + public function getStatusBadge(string $status): string + { + return match ($status) { + self::STATUS_ONLINE => 'Online', + self::STATUS_OFFLINE => 'Offline', + self::STATUS_DEGRADED => 'Degraded', + default => 'Unknown', + }; + } + + public function getStatusColour(string $status): string + { + return match ($status) { + self::STATUS_ONLINE => 'green', + self::STATUS_OFFLINE => 'red', + self::STATUS_DEGRADED => 'yellow', + default => 'gray', + }; + } + + protected function pingServer(array $server): array + { + $connection = (array) ($server['connection'] ?? []); + $type = (string) ($connection['type'] ?? 'stdio'); + + if ($type !== 'stdio') { + return $this->buildResult(self::STATUS_UNKNOWN, sprintf( + "Connection type '%s' health check not supported", + $type, + )); + } + + $command = trim((string) ($connection['command'] ?? '')); + if ($command === '') { + return $this->buildResult(self::STATUS_OFFLINE, 'No command configured'); + } + + $args = array_map(static fn (mixed $value): string => (string) $value, (array) ($connection['args'] ?? [])); + $cwd = $this->resolveEnvVars((string) ($connection['cwd'] ?? getcwd())); + $payload = json_encode([ + 'jsonrpc' => '2.0', + 'method' => 'initialize', + 'params' => [ + 'protocolVersion' => '2024-11-05', + 'capabilities' => new \stdClass, + 'clientInfo' => [ + 'name' => 'mcp-health-check', + 'version' => '1.0.0', + ], + ], + 'id' => 1, + ], JSON_UNESCAPED_SLASHES); + + $result = $this->executeProcess(array_merge([$command], $args), $cwd, $payload.PHP_EOL); + $duration = (int) ($result['response_time_ms'] ?? 0); + $output = (string) ($result['output'] ?? ''); + $error = trim((string) ($result['error'] ?? '')); + $exitCode = (int) ($result['exit_code'] ?? 1); + + if ($exitCode === 0 && $output !== '') { + foreach (preg_split('/\R/', trim($output)) ?: [] as $line) { + $decoded = json_decode($line, true); + + if (is_array($decoded) && isset($decoded['result'])) { + return $this->buildResult(self::STATUS_ONLINE, 'Server responding', [ + 'response_time_ms' => $duration, + 'server_info' => $decoded['result']['serverInfo'] ?? null, + 'protocol_version' => $decoded['result']['protocolVersion'] ?? null, + ]); + } + } + + return $this->buildResult(self::STATUS_DEGRADED, 'Server started but returned unexpected response', [ + 'response_time_ms' => $duration, + 'output' => substr($output, 0, 500), + ]); + } + + return $this->buildResult(self::STATUS_OFFLINE, 'Server failed to start', [ + 'response_time_ms' => $duration, + 'exit_code' => $exitCode, + 'error' => $error !== '' ? substr($error, 0, 500) : null, + ]); + } + + protected function executeProcess(array $command, string $cwd, string $input): array + { + $descriptors = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $startedAt = microtime(true); + $process = @proc_open($command, $descriptors, $pipes, $cwd); + + if (! is_resource($process)) { + return [ + 'exit_code' => 1, + 'output' => '', + 'error' => 'Unable to start process', + 'response_time_ms' => 0, + ]; + } + + fwrite($pipes[0], $input); + fclose($pipes[0]); + + stream_set_blocking($pipes[1], false); + stream_set_blocking($pipes[2], false); + + $stdout = ''; + $stderr = ''; + $timedOut = false; + + while (true) { + $stdout .= stream_get_contents($pipes[1]); + $stderr .= stream_get_contents($pipes[2]); + $status = proc_get_status($process); + + if (! is_array($status) || ! ($status['running'] ?? false)) { + break; + } + + if ((microtime(true) - $startedAt) >= $this->timeout) { + $timedOut = true; + proc_terminate($process, 9); + break; + } + + usleep(100000); + } + + $stdout .= stream_get_contents($pipes[1]); + $stderr .= stream_get_contents($pipes[2]); + + fclose($pipes[1]); + fclose($pipes[2]); + + $exitCode = $timedOut ? 124 : proc_close($process); + + return [ + 'exit_code' => $exitCode, + 'output' => $stdout, + 'error' => $timedOut ? trim($stderr."\nTimed out waiting for MCP response.") : $stderr, + 'response_time_ms' => (int) round((microtime(true) - $startedAt) * 1000), + ]; + } + + protected function buildResult(string $status, string $message, array $extra = []): array + { + return array_merge([ + 'status' => $status, + 'message' => $message, + 'checked_at' => now()->toIso8601String(), + ], array_filter($extra, static fn (mixed $value): bool => $value !== null)); + } + + protected function registeredServers(): array + { + $servers = $this->loadRegistry()['servers'] ?? []; + + return array_values(array_filter(array_map( + static fn (mixed $server): ?string => is_array($server) && isset($server['id']) ? (string) $server['id'] : null, + is_array($servers) ? $servers : [], + ))); + } + + protected function loadRegistry(): array + { + $path = resource_path('mcp/registry.yaml'); + + return file_exists($path) ? (array) Yaml::parseFile($path) : ['servers' => []]; + } + + protected function loadServerConfig(string $serverId): ?array + { + $path = resource_path(sprintf('mcp/servers/%s.yaml', $serverId)); + + return file_exists($path) ? (array) Yaml::parseFile($path) : null; + } + + protected function resolveEnvVars(string $value): string + { + return preg_replace_callback('/\$\{([^}]+)\}/', static function (array $matches): string { + $parts = explode(':-', $matches[1], 2); + $name = $parts[0]; + $default = $parts[1] ?? ''; + + return (string) env($name, $default); + }, $value) ?? $value; + } +} diff --git a/php/Mcp/Services/McpMetricsService.php b/php/Mcp/Services/McpMetricsService.php new file mode 100644 index 0000000..7209600 --- /dev/null +++ b/php/Mcp/Services/McpMetricsService.php @@ -0,0 +1,351 @@ +subDays($days - 1)->startOfDay(); + $currentEnd = CarbonImmutable::now()->endOfDay(); + $previousStart = $currentStart->subDays($days); + $previousEnd = $currentStart->subDay()->endOfDay(); + + $current = $this->statsInRange($currentStart, $currentEnd); + $previous = $this->statsInRange($previousStart, $previousEnd); + + $totalCalls = (int) $current->sum('call_count'); + $successCalls = (int) $current->sum('success_count'); + $errorCalls = (int) $current->sum('error_count'); + $previousCalls = (int) $previous->sum('call_count'); + $totalDuration = (float) $current->sum('total_duration_ms'); + + return [ + 'total_calls' => $totalCalls, + 'success_calls' => $successCalls, + 'error_calls' => $errorCalls, + 'success_rate' => $totalCalls > 0 ? round(($successCalls / $totalCalls) * 100, 1) : 0.0, + 'avg_duration_ms' => $totalCalls > 0 ? round($totalDuration / $totalCalls, 1) : 0.0, + 'calls_trend_percent' => $previousCalls > 0 ? round((($totalCalls - $previousCalls) / $previousCalls) * 100, 1) : 0.0, + 'unique_tools' => $current->pluck('tool_name')->filter()->unique()->count(), + 'unique_servers' => $current->pluck('server_id')->filter()->unique()->count(), + 'period_days' => $days, + ]; + } + + public function getDailyTrend(int $days = 7): Collection + { + $result = collect(); + $start = CarbonImmutable::now()->subDays($days - 1)->startOfDay(); + $end = CarbonImmutable::now()->endOfDay(); + $rows = $this->dailyTrendRows($start, $end)->keyBy('date'); + + for ($offset = 0; $offset < $days; $offset++) { + $date = $start->addDays($offset)->toDateString(); + $row = $rows->get($date); + $totalCalls = (int) ($row->total_calls ?? 0); + $totalSuccess = (int) ($row->total_success ?? 0); + $totalErrors = (int) ($row->total_errors ?? 0); + + $result->push(collect([ + 'date' => $date, + 'date_formatted' => Carbon::parse($date)->format('M j'), + 'total_calls' => $totalCalls, + 'total_success' => $totalSuccess, + 'total_errors' => $totalErrors, + 'success_rate' => $totalCalls > 0 ? round(($totalSuccess / $totalCalls) * 100, 1) : 0.0, + ])); + } + + return $result; + } + + public function getTopTools(int $days = 7, int $limit = 10): Collection + { + if (! Schema::hasTable($this->statsTable)) { + return collect(); + } + + return DB::table($this->statsTable) + ->select('tool_name') + ->selectRaw('SUM(call_count) as call_count') + ->selectRaw('SUM(success_count) as success_count') + ->selectRaw('SUM(error_count) as error_count') + ->selectRaw('SUM(total_duration_ms) as total_duration_ms') + ->whereBetween('date', [ + CarbonImmutable::now()->subDays($days - 1)->toDateString(), + CarbonImmutable::now()->toDateString(), + ]) + ->groupBy('tool_name') + ->orderByDesc('call_count') + ->limit($limit) + ->get() + ->map(function (object $row): Collection { + $callCount = (int) $row->call_count; + $successCount = (int) $row->success_count; + + return collect([ + 'tool_name' => (string) $row->tool_name, + 'call_count' => $callCount, + 'success_count' => $successCount, + 'error_count' => (int) $row->error_count, + 'success_rate' => $callCount > 0 ? round(($successCount / $callCount) * 100, 1) : 0.0, + 'avg_duration_ms' => $callCount > 0 ? round(((int) $row->total_duration_ms) / $callCount, 1) : 0.0, + ]); + }); + } + + public function getServerStats(int $days = 7): Collection + { + if (! Schema::hasTable($this->statsTable)) { + return collect(); + } + + return DB::table($this->statsTable) + ->select('server_id') + ->selectRaw('SUM(call_count) as call_count') + ->selectRaw('SUM(success_count) as success_count') + ->selectRaw('SUM(error_count) as error_count') + ->selectRaw('COUNT(DISTINCT tool_name) as unique_tools') + ->whereBetween('date', [ + CarbonImmutable::now()->subDays($days - 1)->toDateString(), + CarbonImmutable::now()->toDateString(), + ]) + ->groupBy('server_id') + ->orderByDesc('call_count') + ->get() + ->map(function (object $row): Collection { + $callCount = (int) $row->call_count; + $successCount = (int) $row->success_count; + + return collect([ + 'server_id' => (string) $row->server_id, + 'call_count' => $callCount, + 'success_count' => $successCount, + 'error_count' => (int) $row->error_count, + 'unique_tools' => (int) $row->unique_tools, + 'success_rate' => $callCount > 0 ? round(($successCount / $callCount) * 100, 1) : 0.0, + ]); + }); + } + + public function getRecentCalls(int $limit = 20): Collection + { + if (! Schema::hasTable($this->callsTable)) { + return collect(); + } + + return DB::table($this->callsTable) + ->orderByDesc('created_at') + ->limit($limit) + ->get() + ->map(function (object $row): Collection { + $createdAt = isset($row->created_at) ? Carbon::parse((string) $row->created_at) : null; + $durationMs = isset($row->duration_ms) ? (int) $row->duration_ms : null; + + return collect([ + 'id' => $row->id, + 'server_id' => (string) ($row->server_id ?? ''), + 'tool_name' => (string) ($row->tool_name ?? ''), + 'success' => (bool) ($row->success ?? false), + 'duration' => $this->humanDuration($durationMs), + 'duration_ms' => $durationMs, + 'error_message' => $row->error_message, + 'session_id' => $row->session_id, + 'plan_slug' => $row->plan_slug, + 'created_at' => $createdAt?->diffForHumans(), + 'created_at_full' => $createdAt?->toIso8601String(), + ]); + }); + } + + public function getErrorBreakdown(int $days = 7): Collection + { + if (! Schema::hasTable($this->callsTable)) { + return collect(); + } + + return DB::table($this->callsTable) + ->select('tool_name', 'error_code') + ->selectRaw('COUNT(*) as error_count') + ->where('success', false) + ->where('created_at', '>=', CarbonImmutable::now()->subDays($days)->startOfDay()->toDateTimeString()) + ->groupBy('tool_name', 'error_code') + ->orderByDesc('error_count') + ->get() + ->map(fn (object $row): Collection => collect([ + 'tool_name' => (string) ($row->tool_name ?? ''), + 'error_code' => $row->error_code, + 'error_count' => (int) $row->error_count, + ])); + } + + public function getToolPerformance(int $days = 7, int $limit = 10): Collection + { + if (! Schema::hasTable($this->callsTable)) { + return collect(); + } + + $rows = DB::table($this->callsTable) + ->select('tool_name', 'duration_ms') + ->whereNotNull('duration_ms') + ->where('success', true) + ->where('created_at', '>=', CarbonImmutable::now()->subDays($days)->startOfDay()->toDateTimeString()) + ->get() + ->groupBy('tool_name'); + + return $rows->map(function (Collection $items, string $toolName): Collection { + $durations = $items->pluck('duration_ms')->map(static fn (mixed $value): int => (int) $value)->sort()->values(); + $count = $durations->count(); + + return collect([ + 'tool_name' => $toolName, + 'call_count' => $count, + 'min_ms' => $count > 0 ? (int) $durations->first() : 0, + 'max_ms' => $count > 0 ? (int) $durations->last() : 0, + 'avg_ms' => $count > 0 ? round($durations->avg(), 1) : 0.0, + 'p50_ms' => $this->percentile($durations, 50), + 'p95_ms' => $this->percentile($durations, 95), + 'p99_ms' => $this->percentile($durations, 99), + ]); + })->sortByDesc('call_count')->take($limit)->values(); + } + + public function getHourlyDistribution(): Collection + { + $distribution = collect(); + $hours = collect(); + + if (Schema::hasTable($this->callsTable)) { + $hours = DB::table($this->callsTable) + ->select('success', 'created_at') + ->where('created_at', '>=', CarbonImmutable::now()->subHours(24)->toDateTimeString()) + ->get() + ->groupBy(static function (object $row): string { + return Carbon::parse((string) $row->created_at)->format('H'); + }); + } + + for ($hour = 0; $hour < 24; $hour++) { + $key = str_pad((string) $hour, 2, '0', STR_PAD_LEFT); + $rows = $hours->get($key, collect()); + + $distribution->push(collect([ + 'hour' => $key, + 'hour_formatted' => Carbon::createFromTime($hour)->format('ga'), + 'call_count' => $rows->count(), + 'success_count' => $rows->filter(static fn (object $row): bool => (bool) ($row->success ?? false))->count(), + ])); + } + + return $distribution; + } + + public function getPlanActivity(int $days = 7, int $limit = 10): Collection + { + if (! Schema::hasTable($this->callsTable)) { + return collect(); + } + + return DB::table($this->callsTable) + ->select('plan_slug') + ->selectRaw('COUNT(*) as call_count') + ->selectRaw('COUNT(DISTINCT tool_name) as unique_tools') + ->selectRaw('SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as success_count') + ->whereNotNull('plan_slug') + ->where('created_at', '>=', CarbonImmutable::now()->subDays($days)->startOfDay()->toDateTimeString()) + ->groupBy('plan_slug') + ->orderByDesc('call_count') + ->limit($limit) + ->get() + ->map(function (object $row): Collection { + $callCount = (int) $row->call_count; + $successCount = (int) $row->success_count; + + return collect([ + 'plan_slug' => (string) $row->plan_slug, + 'call_count' => $callCount, + 'unique_tools' => (int) $row->unique_tools, + 'success_count' => $successCount, + 'success_rate' => $callCount > 0 ? round(($successCount / $callCount) * 100, 1) : 0.0, + ]); + }); + } + + protected function statsInRange(CarbonImmutable $start, CarbonImmutable $end): Collection + { + if (! Schema::hasTable($this->statsTable)) { + return collect(); + } + + return DB::table($this->statsTable) + ->whereBetween('date', [$start->toDateString(), $end->toDateString()]) + ->get() + ->map(static fn (object $row): Collection => collect((array) $row)); + } + + protected function dailyTrendRows(CarbonImmutable $start, CarbonImmutable $end): Collection + { + if (! Schema::hasTable($this->statsTable)) { + return collect(); + } + + return DB::table($this->statsTable) + ->select('date') + ->selectRaw('SUM(call_count) as total_calls') + ->selectRaw('SUM(success_count) as total_success') + ->selectRaw('SUM(error_count) as total_errors') + ->whereBetween('date', [$start->toDateString(), $end->toDateString()]) + ->groupBy('date') + ->orderBy('date') + ->get(); + } + + protected function percentile(Collection $sortedValues, int $percentile): float + { + $count = $sortedValues->count(); + + if ($count === 0) { + return 0.0; + } + + $index = ($percentile / 100) * ($count - 1); + $lower = (int) floor($index); + $upper = (int) ceil($index); + + if ($lower === $upper) { + return (float) $sortedValues[$lower]; + } + + $fraction = $index - $lower; + + return round(((float) $sortedValues[$lower]) + (((float) $sortedValues[$upper] - (float) $sortedValues[$lower]) * $fraction), 1); + } + + protected function humanDuration(?int $durationMs): string + { + if ($durationMs === null || $durationMs <= 0) { + return '-'; + } + + if ($durationMs < 1000) { + return $durationMs.'ms'; + } + + return round($durationMs / 1000, 2).'s'; + } +} diff --git a/php/Mcp/Services/McpWebhookDispatcher.php b/php/Mcp/Services/McpWebhookDispatcher.php new file mode 100644 index 0000000..78172db --- /dev/null +++ b/php/Mcp/Services/McpWebhookDispatcher.php @@ -0,0 +1,176 @@ +endpointModelClass(); + + if ($endpointClass === null) { + return; + } + + $eventType = 'mcp.tool.executed'; + $payload = [ + 'event' => $eventType, + 'timestamp' => now()->toIso8601String(), + 'data' => [ + 'server_id' => $serverId, + 'tool_name' => $toolName, + 'arguments' => $arguments, + 'success' => $success, + 'duration_ms' => $durationMs, + 'error' => $errorMessage, + ], + ]; + + $query = $endpointClass::query(); + $model = $query->getModel(); + + if (method_exists($model, 'scopeForWorkspace')) { + $query->forWorkspace($workspaceId); + } else { + $query->where('workspace_id', $workspaceId); + } + + if (method_exists($model, 'scopeActive')) { + $query->active(); + } else { + $query->where('active', true); + } + + if (method_exists($model, 'scopeForEvent')) { + $query->forEvent($eventType); + } else { + $query->where(function ($inner) use ($eventType): void { + $inner->whereJsonContains('events', $eventType) + ->orWhereJsonContains('events', '*'); + }); + } + + foreach ($query->get() as $endpoint) { + $this->deliverWebhook($endpoint, $payload); + } + } + + protected function deliverWebhook(object $endpoint, array $payload): void + { + $timestamp = (string) ($payload['timestamp'] ?? now()->toIso8601String()); + $payloadJson = json_encode($payload, JSON_UNESCAPED_SLASHES); + $signature = $this->generateSignature($endpoint, $payloadJson, $timestamp); + + try { + $response = $this->sendWebhook($endpoint, $payloadJson, [ + 'Content-Type' => 'application/json', + 'X-Webhook-Signature' => $signature, + 'X-Webhook-Event' => (string) $payload['event'], + 'X-Webhook-Timestamp' => $timestamp, + ]); + + $this->recordDelivery($endpoint, $payload, $response->status(), $response->body(), $response->successful()); + + if ($response->successful() && method_exists($endpoint, 'recordSuccess')) { + $endpoint->recordSuccess(); + } + + if (! $response->successful() && method_exists($endpoint, 'recordFailure')) { + $endpoint->recordFailure(); + } + } catch (\Throwable $throwable) { + $this->recordDelivery($endpoint, $payload, 0, $throwable->getMessage(), false); + + if (method_exists($endpoint, 'recordFailure')) { + $endpoint->recordFailure(); + } + } + } + + protected function sendWebhook(object $endpoint, string $payloadJson, array $headers): Response + { + return Http::timeout(10) + ->withHeaders($headers) + ->withBody($payloadJson, 'application/json') + ->post((string) $endpoint->url); + } + + protected function recordDelivery(object $endpoint, array $payload, int $responseCode, string $responseBody, bool $successful): void + { + $deliveryClass = $this->deliveryModelClass(); + + if ($deliveryClass === null) { + return; + } + + $deliveryClass::create([ + 'webhook_endpoint_id' => $endpoint->id, + 'event_id' => 'evt_'.Str::random(24), + 'event_type' => (string) $payload['event'], + 'payload' => $payload, + 'response_code' => $responseCode, + 'response_body' => mb_substr($responseBody, 0, 1000), + 'status' => $successful ? 'success' : 'failed', + 'attempt' => 1, + 'delivered_at' => $successful ? now() : null, + ]); + } + + protected function generateSignature(object $endpoint, string $payloadJson, string $timestamp): string + { + if (! method_exists($endpoint, 'generateSignature')) { + return ''; + } + + $method = new ReflectionMethod($endpoint, 'generateSignature'); + + return match (true) { + $method->getNumberOfRequiredParameters() <= 1 => (string) $endpoint->generateSignature($payloadJson), + default => (string) $endpoint->generateSignature($payloadJson, strtotime($timestamp) ?: time()), + }; + } + + protected function endpointModelClass(): ?string + { + foreach ([ + 'Core\\Api\\Models\\WebhookEndpoint', + 'Core\\Mod\\Api\\Models\\WebhookEndpoint', + ] as $class) { + if (class_exists($class)) { + return $class; + } + } + + return null; + } + + protected function deliveryModelClass(): ?string + { + foreach ([ + 'Core\\Api\\Models\\WebhookDelivery', + 'Core\\Mod\\Api\\Models\\WebhookDelivery', + ] as $class) { + if (class_exists($class)) { + return $class; + } + } + + return null; + } +} diff --git a/php/Mcp/Services/OpenApiGenerator.php b/php/Mcp/Services/OpenApiGenerator.php new file mode 100644 index 0000000..357fa5e --- /dev/null +++ b/php/Mcp/Services/OpenApiGenerator.php @@ -0,0 +1,343 @@ + []]; + + protected array $servers = []; + + public function generate(): array + { + $this->loadRegistry(); + $this->loadServers(); + + return [ + 'openapi' => '3.0.3', + 'info' => $this->buildInfo(), + 'servers' => $this->buildServers(), + 'tags' => $this->buildTags(), + 'paths' => $this->buildPaths(), + 'components' => $this->buildComponents(), + ]; + } + + public function toJson(): string + { + return (string) json_encode($this->generate(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + } + + public function toYaml(): string + { + return Yaml::dump($this->generate(), 10, 2); + } + + protected function loadRegistry(): void + { + $path = resource_path('mcp/registry.yaml'); + $this->registry = file_exists($path) ? (array) Yaml::parseFile($path) : ['servers' => []]; + } + + protected function loadServers(): void + { + $this->servers = []; + + foreach ((array) ($this->registry['servers'] ?? []) as $reference) { + if (! is_array($reference) || ! isset($reference['id'])) { + continue; + } + + $id = (string) $reference['id']; + $path = resource_path(sprintf('mcp/servers/%s.yaml', $id)); + $this->servers[$id] = file_exists($path) + ? (array) Yaml::parseFile($path) + : ['id' => $id, 'name' => $id]; + } + } + + protected function buildInfo(): array + { + return [ + 'title' => 'Host UK MCP API', + 'description' => 'HTTP API for MCP server discovery, tool execution, and resource reads.', + 'version' => '1.0.0', + 'contact' => [ + 'name' => 'Host UK Support', + 'url' => 'https://host.uk.com/contact', + 'email' => 'support@host.uk.com', + ], + 'license' => [ + 'name' => 'Proprietary', + ], + ]; + } + + protected function buildServers(): array + { + return [ + [ + 'url' => 'https://mcp.host.uk.com/api/v1/mcp', + 'description' => 'Production', + ], + [ + 'url' => 'https://mcp.test/api/v1/mcp', + 'description' => 'Local development', + ], + ]; + } + + protected function buildTags(): array + { + $tags = [ + ['name' => 'Discovery', 'description' => 'Server and tool discovery endpoints'], + ['name' => 'Execution', 'description' => 'Tool execution and resource endpoints'], + ]; + + foreach ($this->servers as $server) { + $tags[] = [ + 'name' => (string) ($server['name'] ?? $server['id'] ?? 'unknown'), + 'description' => (string) ($server['tagline'] ?? $server['description'] ?? ''), + ]; + } + + return $tags; + } + + protected function buildPaths(): array + { + return [ + '/servers' => [ + 'get' => [ + 'tags' => ['Discovery'], + 'summary' => 'List all MCP servers', + 'operationId' => 'listServers', + 'security' => [['bearerAuth' => []], ['apiKeyAuth' => []]], + 'responses' => [ + '200' => [ + 'description' => 'List of available servers', + 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/ServerList']]], + ], + ], + ], + ], + '/servers/{serverId}' => [ + 'get' => [ + 'tags' => ['Discovery'], + 'summary' => 'Get server details', + 'operationId' => 'getServer', + 'security' => [['bearerAuth' => []], ['apiKeyAuth' => []]], + 'parameters' => [[ + 'name' => 'serverId', + 'in' => 'path', + 'required' => true, + 'schema' => ['type' => 'string'], + ]], + 'responses' => [ + '200' => [ + 'description' => 'Server details', + 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Server']]], + ], + '404' => ['description' => 'Server not found'], + ], + ], + ], + '/servers/{serverId}/tools' => [ + 'get' => [ + 'tags' => ['Discovery'], + 'summary' => 'List server tools', + 'operationId' => 'listServerTools', + 'security' => [['bearerAuth' => []], ['apiKeyAuth' => []]], + 'parameters' => [[ + 'name' => 'serverId', + 'in' => 'path', + 'required' => true, + 'schema' => ['type' => 'string'], + ]], + 'responses' => [ + '200' => [ + 'description' => 'Tool list', + 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/ToolList']]], + ], + ], + ], + ], + '/servers/{serverId}/resources' => [ + 'get' => [ + 'tags' => ['Discovery'], + 'summary' => 'List server resources', + 'operationId' => 'listServerResources', + 'security' => [['bearerAuth' => []], ['apiKeyAuth' => []]], + 'parameters' => [[ + 'name' => 'serverId', + 'in' => 'path', + 'required' => true, + 'schema' => ['type' => 'string'], + ]], + 'responses' => [ + '200' => [ + 'description' => 'Resource list', + 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/ResourceList']]], + ], + ], + ], + ], + '/tools/call' => [ + 'post' => [ + 'tags' => ['Execution'], + 'summary' => 'Execute an MCP tool', + 'operationId' => 'callTool', + 'security' => [['bearerAuth' => []], ['apiKeyAuth' => []]], + 'requestBody' => [ + 'required' => true, + 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/ToolCallRequest']]], + ], + 'responses' => [ + '200' => [ + 'description' => 'Tool executed successfully', + 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/ToolCallResponse']]], + ], + '400' => ['description' => 'Invalid request'], + '401' => ['description' => 'Unauthorized'], + '404' => ['description' => 'Server or tool not found'], + '500' => ['description' => 'Tool execution error'], + ], + ], + ], + '/resources/{uri}' => [ + 'get' => [ + 'tags' => ['Execution'], + 'summary' => 'Read a resource', + 'operationId' => 'readResource', + 'security' => [['bearerAuth' => []], ['apiKeyAuth' => []]], + 'parameters' => [[ + 'name' => 'uri', + 'in' => 'path', + 'required' => true, + 'schema' => ['type' => 'string'], + ]], + 'responses' => [ + '200' => [ + 'description' => 'Resource payload', + 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/ResourceResponse']]], + ], + ], + ], + ], + ]; + } + + protected function buildComponents(): array + { + return [ + 'securitySchemes' => [ + 'bearerAuth' => [ + 'type' => 'http', + 'scheme' => 'bearer', + 'description' => 'API key in bearer format, e.g. hk_xxx_yyy', + ], + 'apiKeyAuth' => [ + 'type' => 'apiKey', + 'in' => 'header', + 'name' => 'X-API-Key', + ], + ], + 'schemas' => [ + 'ServerList' => [ + 'type' => 'object', + 'properties' => [ + 'servers' => ['type' => 'array', 'items' => ['$ref' => '#/components/schemas/ServerSummary']], + 'count' => ['type' => 'integer'], + ], + ], + 'ServerSummary' => [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'string'], + 'name' => ['type' => 'string'], + 'tagline' => ['type' => 'string'], + 'tool_count' => ['type' => 'integer'], + 'resource_count' => ['type' => 'integer'], + ], + ], + 'Server' => [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'string'], + 'name' => ['type' => 'string'], + 'tagline' => ['type' => 'string'], + 'description' => ['type' => 'string'], + 'tools' => ['type' => 'array', 'items' => ['$ref' => '#/components/schemas/Tool']], + 'resources' => ['type' => 'array', 'items' => ['$ref' => '#/components/schemas/Resource']], + ], + ], + 'Tool' => [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string'], + 'description' => ['type' => 'string'], + 'inputSchema' => ['type' => 'object', 'additionalProperties' => true], + ], + ], + 'Resource' => [ + 'type' => 'object', + 'properties' => [ + 'uri' => ['type' => 'string'], + 'name' => ['type' => 'string'], + 'description' => ['type' => 'string'], + 'mimeType' => ['type' => 'string'], + ], + ], + 'ToolList' => [ + 'type' => 'object', + 'properties' => [ + 'server' => ['type' => 'string'], + 'tools' => ['type' => 'array', 'items' => ['$ref' => '#/components/schemas/Tool']], + 'count' => ['type' => 'integer'], + ], + ], + 'ToolCallRequest' => [ + 'type' => 'object', + 'required' => ['server', 'tool'], + 'properties' => [ + 'server' => ['type' => 'string'], + 'tool' => ['type' => 'string'], + 'arguments' => ['type' => 'object', 'additionalProperties' => true], + ], + ], + 'ToolCallResponse' => [ + 'type' => 'object', + 'properties' => [ + 'success' => ['type' => 'boolean'], + 'server' => ['type' => 'string'], + 'tool' => ['type' => 'string'], + 'result' => ['type' => 'object', 'additionalProperties' => true], + 'duration_ms' => ['type' => 'integer'], + 'error' => ['type' => 'string'], + ], + ], + 'ResourceResponse' => [ + 'type' => 'object', + 'properties' => [ + 'uri' => ['type' => 'string'], + 'content' => ['type' => 'object', 'additionalProperties' => true], + ], + ], + 'ResourceList' => [ + 'type' => 'object', + 'properties' => [ + 'server' => ['type' => 'string'], + 'resources' => ['type' => 'array', 'items' => ['$ref' => '#/components/schemas/Resource']], + 'count' => ['type' => 'integer'], + ], + ], + ], + ]; + } +} diff --git a/php/Mcp/Services/ToolRateLimiter.php b/php/Mcp/Services/ToolRateLimiter.php new file mode 100644 index 0000000..188708b --- /dev/null +++ b/php/Mcp/Services/ToolRateLimiter.php @@ -0,0 +1,117 @@ + false, 'remaining' => PHP_INT_MAX, 'retry_after' => null]; + } + + $limit = $this->limitForTool($toolName); + $cacheKey = $this->cacheKey($identifier, $toolName); + $current = (int) Cache::get($cacheKey, 0); + $decaySeconds = (int) config('mcp.rate_limiting.decay_seconds', 60); + + if ($current >= $limit) { + $ttl = $this->ttl($cacheKey, $decaySeconds); + + return [ + 'limited' => true, + 'remaining' => 0, + 'retry_after' => $ttl > 0 ? $ttl : $decaySeconds, + ]; + } + + return [ + 'limited' => false, + 'remaining' => max($limit - $current - 1, 0), + 'retry_after' => null, + ]; + } + + public function hit(string $identifier, string $toolName): void + { + if (! config('mcp.rate_limiting.enabled', true)) { + return; + } + + $cacheKey = $this->cacheKey($identifier, $toolName); + $current = (int) Cache::get($cacheKey, 0); + $decaySeconds = (int) config('mcp.rate_limiting.decay_seconds', 60); + + if ($current === 0) { + Cache::put($cacheKey, 1, $decaySeconds); + + return; + } + + Cache::increment($cacheKey); + } + + public function clear(string $identifier, ?string $toolName = null): void + { + if ($toolName !== null) { + Cache::forget($this->cacheKey($identifier, $toolName)); + + return; + } + + foreach (array_keys((array) config('mcp.rate_limiting.per_tool', [])) as $configuredTool) { + Cache::forget($this->cacheKey($identifier, (string) $configuredTool)); + } + + Cache::forget($this->cacheKey($identifier, '*')); + } + + public function getStatus(string $identifier, string $toolName): array + { + $limit = $this->limitForTool($toolName); + $cacheKey = $this->cacheKey($identifier, $toolName); + $current = (int) Cache::get($cacheKey, 0); + $ttl = $this->ttl($cacheKey, (int) config('mcp.rate_limiting.decay_seconds', 60)); + + return [ + 'limit' => $limit, + 'remaining' => max($limit - $current, 0), + 'reset_at' => $ttl > 0 ? now()->addSeconds($ttl)->toIso8601String() : null, + ]; + } + + protected function limitForTool(string $toolName): int + { + $perTool = (array) config('mcp.rate_limiting.per_tool', []); + + if (array_key_exists($toolName, $perTool)) { + return (int) $perTool[$toolName]; + } + + return (int) config('mcp.rate_limiting.calls_per_minute', 60); + } + + protected function cacheKey(string $identifier, string $toolName): string + { + return self::CACHE_PREFIX.$identifier.':'.$toolName; + } + + protected function ttl(string $cacheKey, int $default): int + { + try { + $ttl = Cache::ttl($cacheKey); + + return is_int($ttl) ? $ttl : $default; + } catch (\Throwable) { + return $default; + } + } +} diff --git a/php/Mcp/Transport/Contracts/McpToolHandler.php b/php/Mcp/Transport/Contracts/McpToolHandler.php new file mode 100644 index 0000000..27d067e --- /dev/null +++ b/php/Mcp/Transport/Contracts/McpToolHandler.php @@ -0,0 +1,16 @@ +sessionId; + } + + public function setSessionId(?string $sessionId): void + { + $this->sessionId = $sessionId; + } + + public function getCurrentPlan(): ?object + { + return $this->currentPlan; + } + + public function setCurrentPlan(?object $plan): void + { + $this->currentPlan = $plan; + } + + public function sendNotification(string $method, array $params = []): void + { + if ($this->notificationCallback instanceof Closure) { + ($this->notificationCallback)($method, $params); + } + } + + public function logToSession(string $message, string $type = 'info', array $data = []): void + { + if ($this->logCallback instanceof Closure) { + ($this->logCallback)($message, $type, $data); + } + } + + public function setNotificationCallback(?Closure $callback): void + { + $this->notificationCallback = $callback; + } + + public function setLogCallback(?Closure $callback): void + { + $this->logCallback = $callback; + } + + public function hasSession(): bool + { + return $this->sessionId !== null; + } + + public function hasPlan(): bool + { + return $this->currentPlan !== null; + } +} diff --git a/php/Mod/Mcp/Services/AgentSessionService.php b/php/Mod/Mcp/Services/AgentSessionService.php new file mode 100644 index 0000000..a1b5dfd --- /dev/null +++ b/php/Mod/Mcp/Services/AgentSessionService.php @@ -0,0 +1,249 @@ +update(['workspace_id' => $workspaceId]); + } + + if ($initialContext !== []) { + $session->updateContextSummary($initialContext); + } + + $this->cacheActiveSession($session); + + return $session; + } + + public function get(string $sessionId): ?AgentSession + { + return AgentSession::query()->where('session_id', $sessionId)->first(); + } + + public function resume(string $sessionId): ?AgentSession + { + $session = $this->get($sessionId); + + if (! $session instanceof AgentSession) { + return null; + } + + if ($session->status === AgentSession::STATUS_PAUSED) { + $session->resume(); + } + + $session->touchActivity(); + $this->cacheActiveSession($session); + + return $session; + } + + public function getActiveSessions(?int $workspaceId = null): Collection + { + $query = AgentSession::query()->active(); + + if ($workspaceId !== null) { + $query->where('workspace_id', $workspaceId); + } + + return $query->orderByDesc('last_active_at')->get(); + } + + public function getSessionsForPlan(AgentPlan $plan): Collection + { + return AgentSession::query() + ->forPlan($plan) + ->orderByDesc('created_at') + ->get(); + } + + public function getLatestSessionForPlan(AgentPlan $plan): ?AgentSession + { + return AgentSession::query() + ->forPlan($plan) + ->orderByDesc('created_at') + ->first(); + } + + public function end(string $sessionId, string $status = AgentSession::STATUS_COMPLETED, ?string $summary = null): ?AgentSession + { + $session = $this->get($sessionId); + + if (! $session instanceof AgentSession) { + return null; + } + + $session->end($status, $summary); + $this->clearCachedSession($session); + + return $session; + } + + public function pause(string $sessionId): ?AgentSession + { + $session = $this->get($sessionId); + + if (! $session instanceof AgentSession) { + return null; + } + + $session->pause(); + + return $session; + } + + public function prepareHandoff(string $sessionId, string $summary, array $nextSteps = [], array $blockers = [], array $contextForNext = []): ?AgentSession + { + $session = $this->get($sessionId); + + if (! $session instanceof AgentSession) { + return null; + } + + $session->prepareHandoff($summary, $nextSteps, $blockers, $contextForNext); + + return $session; + } + + public function getHandoffContext(string $sessionId): ?array + { + return $this->get($sessionId)?->getHandoffContext(); + } + + public function continueFrom(string $previousSessionId, string $newAgentType): ?AgentSession + { + $previousSession = $this->get($previousSessionId); + + if (! $previousSession instanceof AgentSession) { + return null; + } + + $handoffContext = $previousSession->getHandoffContext(); + $handoffNotes = (array) ($handoffContext['handoff_notes'] ?? []); + $contextForNext = (array) ($handoffNotes['context_for_next'] ?? []); + + $newSession = $this->start( + $newAgentType, + $previousSession->plan, + $previousSession->workspace_id, + [ + 'continued_from' => $previousSession->session_id, + 'previous_agent' => $previousSession->agent_type, + 'handoff_notes' => $handoffNotes, + 'inherited_context' => $contextForNext !== [] ? $contextForNext : ($handoffContext['context_summary'] ?? null), + ], + ); + + $previousSession->end(AgentSession::STATUS_HANDED_OFF, 'Handed off to '.$newAgentType, $previousSession->handoff_notes); + $this->clearCachedSession($previousSession); + + return $newSession; + } + + public function setState(string $sessionId, string $key, mixed $value, ?int $ttl = null): void + { + Cache::put( + self::CACHE_PREFIX.$sessionId.':'.$key, + $value, + $ttl ?? $this->cacheTtl(), + ); + } + + public function getState(string $sessionId, string $key, mixed $default = null): mixed + { + return Cache::get(self::CACHE_PREFIX.$sessionId.':'.$key, $default); + } + + public function exists(string $sessionId): bool + { + return AgentSession::query()->where('session_id', $sessionId)->exists(); + } + + public function isActive(string $sessionId): bool + { + return $this->get($sessionId)?->isActive() ?? false; + } + + public function getSessionStats(?int $workspaceId = null, int $days = 7): array + { + $query = AgentSession::query()->where('created_at', '>=', now()->subDays($days)); + + if ($workspaceId !== null) { + $query->where('workspace_id', $workspaceId); + } + + $sessions = $query->get(); + $byStatus = $sessions->groupBy('status')->map->count()->toArray(); + $byAgentType = $sessions->groupBy('agent_type')->map->count()->toArray(); + $avgDuration = round((float) $sessions + ->where('status', AgentSession::STATUS_COMPLETED) + ->avg(static fn (AgentSession $session): int => $session->getDuration() ?? 0), 1); + + return [ + 'total' => $sessions->count(), + 'active' => $sessions->where('status', AgentSession::STATUS_ACTIVE)->count(), + 'by_status' => $byStatus, + 'by_agent_type' => $byAgentType, + 'avg_duration_minutes' => $avgDuration, + 'period_days' => $days, + ]; + } + + public function cleanupStaleSessions(int $hoursInactive = 24): int + { + $cutoff = now()->subHours($hoursInactive); + $sessions = AgentSession::query() + ->active() + ->where('last_active_at', '<', $cutoff) + ->get(); + + foreach ($sessions as $session) { + $session->fail('Session timed out due to inactivity'); + $this->clearCachedSession($session); + } + + return $sessions->count(); + } + + protected function cacheActiveSession(AgentSession $session): void + { + Cache::put(self::CACHE_PREFIX.'active:'.$session->session_id, [ + 'session_id' => $session->session_id, + 'agent_type' => $session->agent_type, + 'plan_id' => $session->agent_plan_id, + 'workspace_id' => $session->workspace_id, + 'started_at' => $session->started_at?->toIso8601String(), + ], $this->cacheTtl()); + } + + protected function clearCachedSession(AgentSession $session): void + { + Cache::forget(self::CACHE_PREFIX.'active:'.$session->session_id); + } + + protected function cacheTtl(): int + { + return (int) config('mcp.session.cache_ttl', 86400); + } +} diff --git a/php/resources/mcp/registry.yaml b/php/resources/mcp/registry.yaml new file mode 100644 index 0000000..473ac93 --- /dev/null +++ b/php/resources/mcp/registry.yaml @@ -0,0 +1,3 @@ +servers: + - id: host-hub + - id: marketing diff --git a/php/tests/Feature/Mcp/Resources/AppConfigTest.php b/php/tests/Feature/Mcp/Resources/AppConfigTest.php new file mode 100644 index 0000000..49e80c7 --- /dev/null +++ b/php/tests/Feature/Mcp/Resources/AppConfigTest.php @@ -0,0 +1,44 @@ +set('app.name', 'Host Hub'); + config()->set('app.env', 'testing'); + config()->set('app.debug', true); + config()->set('app.url', 'https://host.test'); + + $response = (new AppConfig)->handle(new Request); + $payload = json_decode($response->content, true, 512, JSON_THROW_ON_ERROR); + + expect($payload['name'])->toBe('Host Hub') + ->and($payload['url'])->toBe('https://host.test'); +}); + +test('AppConfig_handle_Bad_keeps_missing_values_as_nulls_instead_of_throwing', function (): void { + config()->set('app.name', null); + config()->set('app.url', null); + + $payload = json_decode((new AppConfig)->handle(new Request)->content, true, 512, JSON_THROW_ON_ERROR); + + expect($payload['name'])->toBeNull() + ->and($payload['url'])->toBeNull(); +}); + +test('AppConfig_handle_Ugly_preserves_json_encoding_for_slash_containing_urls', function (): void { + config()->set('app.url', 'https://host.test/api/v1'); + + $content = (new AppConfig)->handle(new Request)->content; + + expect($content)->toContain('https://host.test/api/v1'); +}); diff --git a/php/tests/Feature/Mcp/Resources/ContentResourceTest.php b/php/tests/Feature/Mcp/Resources/ContentResourceTest.php new file mode 100644 index 0000000..4766855 --- /dev/null +++ b/php/tests/Feature/Mcp/Resources/ContentResourceTest.php @@ -0,0 +1,83 @@ + 1, 'slug' => 'host']; + $item = (object) [ + 'title' => 'Launch Post', + 'slug' => 'launch-post', + 'type' => 'post', + 'status' => 'publish', + 'author' => (object) ['name' => 'Virgil'], + 'categories' => [(object) ['name' => 'News']], + 'tags' => [(object) ['name' => 'Launch']], + 'excerpt' => 'Big launch.', + 'content_markdown' => '# Hello', + 'created_at' => now(), + 'updated_at' => now(), + ]; + + $resource = new class($workspace, $item) extends ContentResource + { + public function __construct(private object $workspaceFixture, private object $itemFixture) + { + } + + protected function resolveWorkspace(string $identifier): ?object + { + return $this->workspaceFixture; + } + + protected function resolveContentItem(object $workspace, string $identifier): ?object + { + return $this->itemFixture; + } + }; + + $content = $resource->handle(new Request(['uri' => 'content://host/launch-post']))->content; + + expect($content)->toContain("title: Launch Post") + ->and($content)->toContain('> Big launch.') + ->and($content)->toContain('# Hello'); +}); + +test('ContentResource_handle_Bad_rejects_invalid_content_uris', function (): void { + $resource = new class extends ContentResource {}; + + expect($resource->handle(new Request(['uri' => 'invalid://x']))->content) + ->toBe('Invalid URI format. Expected: content://{workspace}/{slug}'); +}); + +test('ContentResource_list_Ugly_can_be_overridden_to_surface_resource_lists_without_the_database', function (): void { + $resource = new class extends ContentResource + { + protected function listResources(): array + { + return [[ + 'uri' => 'content://host/launch-post', + 'name' => 'Launch Post', + 'description' => 'Post: Launch Post', + 'mimeType' => 'text/markdown', + ]]; + } + }; + + expect(mcpInvokeProtected($resource, 'listResources'))->toBe([[ + 'uri' => 'content://host/launch-post', + 'name' => 'Launch Post', + 'description' => 'Post: Launch Post', + 'mimeType' => 'text/markdown', + ]]); +}); diff --git a/php/tests/Feature/Mcp/Resources/DatabaseSchemaTest.php b/php/tests/Feature/Mcp/Resources/DatabaseSchemaTest.php new file mode 100644 index 0000000..3f2982f --- /dev/null +++ b/php/tests/Feature/Mcp/Resources/DatabaseSchemaTest.php @@ -0,0 +1,63 @@ + 'id', 'Type' => 'bigint']]; + } + }; + + $payload = json_decode($resource->handle(new Request)->content, true, 512, JSON_THROW_ON_ERROR); + + expect($payload['users'][0]['Field'])->toBe('id'); +}); + +test('DatabaseSchema_handle_Bad_returns_an_empty_json_object_when_no_tables_are_available', function (): void { + $resource = new class extends DatabaseSchema + { + protected function tables(): array + { + return []; + } + }; + + expect($resource->handle(new Request)->content)->toBe('[]'); +}); + +test('DatabaseSchema_handle_Ugly_aggregates_multiple_tables_in_a_single_response', function (): void { + $resource = new class extends DatabaseSchema + { + protected function tables(): array + { + return ['users', 'posts']; + } + + protected function describeTable(string $tableName): array + { + return [['table' => $tableName]]; + } + }; + + $payload = json_decode($resource->handle(new Request)->content, true, 512, JSON_THROW_ON_ERROR); + + expect(array_keys($payload))->toBe(['users', 'posts']); +}); diff --git a/php/tests/Feature/Mcp/Services/AgentSessionServiceTest.php b/php/tests/Feature/Mcp/Services/AgentSessionServiceTest.php new file mode 100644 index 0000000..62a4a01 --- /dev/null +++ b/php/tests/Feature/Mcp/Services/AgentSessionServiceTest.php @@ -0,0 +1,52 @@ +create(['workspace_id' => $workspace->id]); + $service = new AgentSessionService; + + $session = $service->start('opus', $plan, $workspace->id, ['task' => 'draft']); + + expect($session)->toBeInstanceOf(AgentSession::class) + ->and($service->exists($session->session_id))->toBeTrue() + ->and(Cache::get('mcp_session:active:'.$session->session_id)['workspace_id'])->toBe($workspace->id); +}); + +test('AgentSessionService_end_Bad_returns_null_for_unknown_sessions', function (): void { + $service = new AgentSessionService; + + expect($service->end('missing-session'))->toBeNull() + ->and($service->getHandoffContext('missing-session'))->toBeNull(); +}); + +test('AgentSessionService_continueFrom_Ugly_hands_off_the_previous_session_and_starts_a_follow_up', function (): void { + $workspace = createWorkspace(); + $plan = AgentPlan::factory()->create(['workspace_id' => $workspace->id]); + $service = new AgentSessionService; + $first = $service->start('opus', $plan, $workspace->id, ['task' => 'phase-one']); + + $service->prepareHandoff($first->session_id, 'Done', ['next'], ['none'], ['checkpoint' => 'alpha']); + $second = $service->continueFrom($first->session_id, 'sonnet'); + + expect($second)->toBeInstanceOf(AgentSession::class) + ->and($first->fresh()->status)->toBe(AgentSession::STATUS_HANDED_OFF) + ->and($second->context_summary['continued_from'])->toBe($first->session_id) + ->and($second->context_summary['previous_agent'])->toBe('opus'); +}); diff --git a/php/tests/Feature/Mcp/Services/CircuitBreakerTest.php b/php/tests/Feature/Mcp/Services/CircuitBreakerTest.php new file mode 100644 index 0000000..a94d1f9 --- /dev/null +++ b/php/tests/Feature/Mcp/Services/CircuitBreakerTest.php @@ -0,0 +1,69 @@ +set('mcp.circuit_breaker.default_threshold', 2); + config()->set('mcp.circuit_breaker.default_reset_timeout', 60); + config()->set('mcp.circuit_breaker.default_failure_window', 120); +}); + +test('CircuitBreaker_call_Good_allows_closed_operations_and_records_success', function (): void { + $breaker = new CircuitBreaker; + + $result = $breaker->call('agentic', static fn (): string => 'ok'); + + expect($result)->toBe('ok') + ->and($breaker->getState('agentic'))->toBe(CircuitBreaker::STATE_CLOSED) + ->and($breaker->getStats('agentic')['successes'])->toBe(1); +}); + +test('CircuitBreaker_call_Bad_trips_open_and_fails_fast_without_a_fallback', function (): void { + $breaker = new CircuitBreaker; + + try { + $breaker->call('agentic', static function (): never { + throw new RuntimeException('Connection refused'); + }); + } catch (RuntimeException) { + } + + try { + $breaker->call('agentic', static function (): never { + throw new RuntimeException('Connection refused'); + }); + } catch (RuntimeException) { + } + + expect($breaker->getState('agentic'))->toBe(CircuitBreaker::STATE_OPEN); + + $breaker->call('agentic', static fn (): string => 'ignored'); +})->throws(CircuitOpenException::class); + +test('CircuitBreaker_call_Ugly_uses_fallback_when_half_open_trial_is_already_locked', function (): void { + $breaker = new CircuitBreaker; + + Cache::put('circuit_breaker:content:state', CircuitBreaker::STATE_OPEN, 86400); + Cache::put('circuit_breaker:content:opened_at', time() - 120, 86400); + Cache::put('circuit_breaker:content:trial_lock', true, 30); + + $result = $breaker->call( + 'content', + static fn (): string => 'never', + static fn (): string => 'fallback', + ); + + expect($result)->toBe('fallback') + ->and($breaker->getState('content'))->toBe(CircuitBreaker::STATE_HALF_OPEN); +}); diff --git a/php/tests/Feature/Mcp/Services/DataRedactorTest.php b/php/tests/Feature/Mcp/Services/DataRedactorTest.php new file mode 100644 index 0000000..14ad77b --- /dev/null +++ b/php/tests/Feature/Mcp/Services/DataRedactorTest.php @@ -0,0 +1,51 @@ +redact([ + 'password' => 'super-secret', + 'header' => 'Bearer sk_1234567890abcdefghijklmn', + 'card' => '4111-1111-1111-1111', + ]); + + expect($redacted['password'])->toBe('[REDACTED]') + ->and($redacted['header'])->toContain('Bearer [REDACTED]') + ->and($redacted['card'])->toBe('[REDACTED]'); +}); + +test('DataRedactor_summarize_Bad_partially_redacts_pii_and_truncates_large_arrays', function (): void { + $service = new DataRedactor; + + $summary = $service->summarize([ + 'email' => 'somebody@example.com', + 'items' => range(1, 12), + ]); + + expect($summary['email'])->toBe('som***com') + ->and($summary['items']['_truncated'])->toBe('... and 2 more items'); +}); + +test('DataRedactor_redact_Ugly_stops_when_the_maximum_depth_is_exceeded', function (): void { + $service = new DataRedactor; + + $redacted = $service->redact([ + 'level1' => [ + 'level2' => [ + 'level3' => ['secret' => 'value'], + ], + ], + ], 2); + + expect($redacted['level1']['level2'])->toBe('[MAX_DEPTH_EXCEEDED]'); +}); diff --git a/php/tests/Feature/Mcp/Services/McpHealthServiceTest.php b/php/tests/Feature/Mcp/Services/McpHealthServiceTest.php new file mode 100644 index 0000000..2b919b2 --- /dev/null +++ b/php/tests/Feature/Mcp/Services/McpHealthServiceTest.php @@ -0,0 +1,99 @@ + $serverId, + 'connection' => [ + 'type' => 'stdio', + 'command' => 'php', + 'args' => ['artisan', 'mcp:agent-server'], + 'cwd' => '${APP_ROOT:-/tmp}', + ], + ]; + } + + protected function executeProcess(array $command, string $cwd, string $input): array + { + return [ + 'exit_code' => 0, + 'output' => json_encode([ + 'jsonrpc' => '2.0', + 'result' => [ + 'serverInfo' => ['name' => 'Host Hub'], + 'protocolVersion' => '2024-11-05', + ], + ]), + 'error' => '', + 'response_time_ms' => 42, + ]; + } + }; + + $result = $service->check('host-hub', true); + + expect($result['status'])->toBe(McpHealthService::STATUS_ONLINE) + ->and($result['protocol_version'])->toBe('2024-11-05') + ->and($result['response_time_ms'])->toBe(42); +}); + +test('McpHealthService_check_Bad_returns_unknown_for_missing_registry_entries', function (): void { + $service = new class extends McpHealthService + { + protected function loadServerConfig(string $serverId): ?array + { + return null; + } + }; + + expect($service->check('missing', true)['status'])->toBe(McpHealthService::STATUS_UNKNOWN); +}); + +test('McpHealthService_check_Ugly_marks_successful_but_malformed_stdio_output_as_degraded', function (): void { + $service = new class extends McpHealthService + { + protected function loadServerConfig(string $serverId): ?array + { + return [ + 'id' => $serverId, + 'connection' => [ + 'type' => 'stdio', + 'command' => 'php', + ], + ]; + } + + protected function executeProcess(array $command, string $cwd, string $input): array + { + return [ + 'exit_code' => 0, + 'output' => "not-json\nstill-not-json", + 'error' => '', + 'response_time_ms' => 15, + ]; + } + }; + + $result = $service->check('marketing', true); + + expect($result['status'])->toBe(McpHealthService::STATUS_DEGRADED) + ->and($result['output'])->toContain('not-json'); +}); diff --git a/php/tests/Feature/Mcp/Services/McpMetricsServiceTest.php b/php/tests/Feature/Mcp/Services/McpMetricsServiceTest.php new file mode 100644 index 0000000..2150dee --- /dev/null +++ b/php/tests/Feature/Mcp/Services/McpMetricsServiceTest.php @@ -0,0 +1,100 @@ +id(); + $table->date('date'); + $table->string('server_id'); + $table->string('tool_name'); + $table->unsignedInteger('call_count')->default(0); + $table->unsignedInteger('success_count')->default(0); + $table->unsignedInteger('error_count')->default(0); + $table->unsignedInteger('total_duration_ms')->default(0); + $table->timestamps(); + }); + + Schema::create('mcp_tool_calls', function (Blueprint $table): void { + $table->id(); + $table->string('server_id'); + $table->string('tool_name'); + $table->string('session_id')->nullable(); + $table->boolean('success')->default(true); + $table->unsignedInteger('duration_ms')->nullable(); + $table->string('error_message')->nullable(); + $table->string('error_code')->nullable(); + $table->string('plan_slug')->nullable(); + $table->timestamps(); + }); +}); + +afterEach(function (): void { + CarbonImmutable::setTestNow(); +}); + +test('McpMetricsService_getOverview_Good_returns_current_period_dashboard_metrics', function (): void { + DB::table('mcp_tool_call_stats')->insert([ + ['date' => '2026-04-24', 'server_id' => 'host-hub', 'tool_name' => 'send_email', 'call_count' => 10, 'success_count' => 9, 'error_count' => 1, 'total_duration_ms' => 1000, 'created_at' => now(), 'updated_at' => now()], + ['date' => '2026-04-25', 'server_id' => 'marketing', 'tool_name' => 'list_posts', 'call_count' => 5, 'success_count' => 5, 'error_count' => 0, 'total_duration_ms' => 750, 'created_at' => now(), 'updated_at' => now()], + ]); + + $service = new McpMetricsService; + $overview = $service->getOverview(2); + $trend = $service->getDailyTrend(2); + + expect($overview['total_calls'])->toBe(15) + ->and($overview['success_rate'])->toBe(93.3) + ->and($trend[0]['date'])->toBe('2026-04-24') + ->and($trend[1]['total_calls'])->toBe(5); +}); + +test('McpMetricsService_getErrorBreakdown_Bad_groups_errors_and_plan_activity_from_raw_calls', function (): void { + DB::table('mcp_tool_calls')->insert([ + ['server_id' => 'host-hub', 'tool_name' => 'send_email', 'success' => false, 'duration_ms' => 100, 'error_message' => 'Bad gateway', 'error_code' => '502', 'plan_slug' => 'plan-a', 'created_at' => now(), 'updated_at' => now()], + ['server_id' => 'host-hub', 'tool_name' => 'send_email', 'success' => false, 'duration_ms' => 120, 'error_message' => 'Bad gateway', 'error_code' => '502', 'plan_slug' => 'plan-a', 'created_at' => now(), 'updated_at' => now()], + ['server_id' => 'marketing', 'tool_name' => 'list_posts', 'success' => true, 'duration_ms' => 80, 'plan_slug' => 'plan-b', 'created_at' => now(), 'updated_at' => now()], + ]); + + $service = new McpMetricsService; + $errors = $service->getErrorBreakdown(7); + $plans = $service->getPlanActivity(7); + + expect($errors[0]['tool_name'])->toBe('send_email') + ->and($errors[0]['error_count'])->toBe(2) + ->and($plans[0]['plan_slug'])->toBe('plan-a') + ->and($plans[0]['success_rate'])->toBe(0.0); +}); + +test('McpMetricsService_getToolPerformance_Ugly_uses_linear_interpolation_for_percentiles', function (): void { + DB::table('mcp_tool_calls')->insert([ + ['server_id' => 'host-hub', 'tool_name' => 'send_email', 'success' => true, 'duration_ms' => 100, 'created_at' => now(), 'updated_at' => now()], + ['server_id' => 'host-hub', 'tool_name' => 'send_email', 'success' => true, 'duration_ms' => 200, 'created_at' => now(), 'updated_at' => now()], + ['server_id' => 'host-hub', 'tool_name' => 'send_email', 'success' => true, 'duration_ms' => 300, 'created_at' => now(), 'updated_at' => now()], + ['server_id' => 'host-hub', 'tool_name' => 'send_email', 'success' => true, 'duration_ms' => 400, 'created_at' => now(), 'updated_at' => now()], + ]); + + $service = new McpMetricsService; + $performance = $service->getToolPerformance(7, 1)->first(); + + expect($performance['p50_ms'])->toBe(250.0) + ->and($performance['p95_ms'])->toBe(385.0) + ->and($performance['p99_ms'])->toBe(397.0); +}); diff --git a/php/tests/Feature/Mcp/Services/McpWebhookDispatcherTest.php b/php/tests/Feature/Mcp/Services/McpWebhookDispatcherTest.php new file mode 100644 index 0000000..ce7e640 --- /dev/null +++ b/php/tests/Feature/Mcp/Services/McpWebhookDispatcherTest.php @@ -0,0 +1,187 @@ +records = array_values(array_filter( + $this->records, + static fn (McpWebhookDispatcherEndpointStub $endpoint): bool => $endpoint->workspace_id === $workspaceId, + )); + + return $this; + } + + public function active(): self + { + $this->records = array_values(array_filter( + $this->records, + static fn (McpWebhookDispatcherEndpointStub $endpoint): bool => $endpoint->active, + )); + + return $this; + } + + public function forEvent(string $eventType): self + { + $this->records = array_values(array_filter( + $this->records, + static fn (McpWebhookDispatcherEndpointStub $endpoint): bool => in_array($eventType, $endpoint->events, true), + )); + + return $this; + } + + public function get(): array + { + return $this->records; + } +} + +final class McpWebhookDispatcherEndpointStub +{ + public static array $records = []; + + public function __construct( + public array $events, + public int $workspace_id = 1, + public bool $active = true, + public string $url = 'https://hooks.example.test/mcp', + public int $id = 1, + public int $successes = 0, + public int $failures = 0, + ) { + } + + public static function query(): McpWebhookDispatcherEndpointBuilderStub + { + return new McpWebhookDispatcherEndpointBuilderStub(static::$records); + } + + public function generateSignature(string $payload): string + { + return 'sig:'.sha1($payload); + } + + public function recordSuccess(): void + { + $this->successes++; + } + + public function recordFailure(): void + { + $this->failures++; + } +} + +final class McpWebhookDispatcherDeliveryStub +{ + public static array $records = []; + + public static function create(array $attributes): void + { + static::$records[] = $attributes; + } +} + +beforeEach(function (): void { + Http::preventStrayRequests(); + McpWebhookDispatcherEndpointStub::$records = []; + McpWebhookDispatcherDeliveryStub::$records = []; +}); + +test('McpWebhookDispatcher_dispatchToolExecuted_Good_delivers_to_matching_endpoints_and_records_success', function (): void { + McpWebhookDispatcherEndpointStub::$records = [ + new McpWebhookDispatcherEndpointStub(['mcp.tool.executed']), + ]; + + Http::fake([ + 'https://hooks.example.test/mcp' => Http::response('ok', 200), + ]); + + $dispatcher = new class extends McpWebhookDispatcher + { + protected function endpointModelClass(): ?string + { + return McpWebhookDispatcherEndpointStub::class; + } + + protected function deliveryModelClass(): ?string + { + return McpWebhookDispatcherDeliveryStub::class; + } + }; + + $dispatcher->dispatchToolExecuted(1, 'host-hub', 'send_email', ['to' => 'a@example.com'], true, 42); + + expect(McpWebhookDispatcherDeliveryStub::$records)->toHaveCount(1) + ->and(McpWebhookDispatcherDeliveryStub::$records[0]['status'])->toBe('success') + ->and(McpWebhookDispatcherEndpointStub::$records[0]->successes)->toBe(1); +}); + +test('McpWebhookDispatcher_dispatchToolExecuted_Bad_noops_when_no_endpoints_are_subscribed', function (): void { + $dispatcher = new class extends McpWebhookDispatcher + { + protected function endpointModelClass(): ?string + { + return McpWebhookDispatcherEndpointStub::class; + } + + protected function deliveryModelClass(): ?string + { + return McpWebhookDispatcherDeliveryStub::class; + } + }; + + $dispatcher->dispatchToolExecuted(1, 'host-hub', 'send_email', [], true, 10); + + expect(McpWebhookDispatcherDeliveryStub::$records)->toHaveCount(0); +}); + +test('McpWebhookDispatcher_dispatchToolExecuted_Ugly_records_failed_deliveries_and_failure_counts', function (): void { + McpWebhookDispatcherEndpointStub::$records = [ + new McpWebhookDispatcherEndpointStub(['mcp.tool.executed']), + ]; + + Http::fake([ + 'https://hooks.example.test/mcp' => Http::response('broken', 500), + ]); + + $dispatcher = new class extends McpWebhookDispatcher + { + protected function endpointModelClass(): ?string + { + return McpWebhookDispatcherEndpointStub::class; + } + + protected function deliveryModelClass(): ?string + { + return McpWebhookDispatcherDeliveryStub::class; + } + }; + + $dispatcher->dispatchToolExecuted(1, 'host-hub', 'send_email', [], false, 10, 'failed'); + + expect(McpWebhookDispatcherDeliveryStub::$records[0]['status'])->toBe('failed') + ->and(McpWebhookDispatcherEndpointStub::$records[0]->failures)->toBe(1); +}); diff --git a/php/tests/Feature/Mcp/Services/OpenApiGeneratorTest.php b/php/tests/Feature/Mcp/Services/OpenApiGeneratorTest.php new file mode 100644 index 0000000..ac6816d --- /dev/null +++ b/php/tests/Feature/Mcp/Services/OpenApiGeneratorTest.php @@ -0,0 +1,48 @@ +generate(); + + expect($document['openapi'])->toBe('3.0.3') + ->and($document['tags'][2]['name'])->toBe('Host Hub') + ->and($document['paths'])->toHaveKey('/tools/call'); +}); + +test('OpenApiGenerator_generate_Bad_falls_back_to_registry_ids_when_server_yaml_is_missing', function (): void { + File::put(resource_path('mcp/registry.yaml'), "servers:\n - id: marketing\n"); + File::delete(resource_path('mcp/servers/marketing.yaml')); + + $generator = new OpenApiGenerator; + $document = $generator->generate(); + + expect($document['tags'][2]['name'])->toBe('marketing'); +}); + +test('OpenApiGenerator_toJson_Ugly_and_toYaml_keep_the_document_at_openapi_3_0_3_not_3_1', function (): void { + File::put(resource_path('mcp/registry.yaml'), "servers: []\n"); + + $generator = new OpenApiGenerator; + + expect($generator->toJson())->toContain('"openapi": "3.0.3"') + ->and($generator->toYaml())->toContain("openapi: 3.0.3\n") + ->and($generator->toYaml())->not->toContain('3.1'); +}); diff --git a/php/tests/Feature/Mcp/Services/ToolRateLimiterTest.php b/php/tests/Feature/Mcp/Services/ToolRateLimiterTest.php new file mode 100644 index 0000000..5758798 --- /dev/null +++ b/php/tests/Feature/Mcp/Services/ToolRateLimiterTest.php @@ -0,0 +1,54 @@ +set('mcp.rate_limiting.enabled', true); + config()->set('mcp.rate_limiting.decay_seconds', 60); + config()->set('mcp.rate_limiting.calls_per_minute', 2); + config()->set('mcp.rate_limiting.per_tool', ['send_email' => 1]); +}); + +test('ToolRateLimiter_check_Good_reports_remaining_calls_before_the_limit_is_hit', function (): void { + $limiter = new ToolRateLimiter; + + $status = $limiter->check('sess-1', 'list_posts'); + $limiter->hit('sess-1', 'list_posts'); + $afterHit = $limiter->getStatus('sess-1', 'list_posts'); + + expect($status['limited'])->toBeFalse() + ->and($status['remaining'])->toBe(1) + ->and($afterHit['remaining'])->toBe(1); +}); + +test('ToolRateLimiter_check_Bad_applies_tool_specific_limits_and_returns_retry_after_when_limited', function (): void { + $limiter = new ToolRateLimiter; + + $limiter->hit('sess-2', 'send_email'); + $result = $limiter->check('sess-2', 'send_email'); + + expect($result['limited'])->toBeTrue() + ->and($result['remaining'])->toBe(0) + ->and($result['retry_after'])->toBeInt(); +}); + +test('ToolRateLimiter_hit_Ugly_uses_put_for_the_first_call_and_increment_for_subsequent_calls', function (): void { + Cache::shouldReceive('get')->once()->with('mcp_rate_limit:sess-3:list_posts', 0)->andReturn(0); + Cache::shouldReceive('put')->once()->with('mcp_rate_limit:sess-3:list_posts', 1, 60); + Cache::shouldReceive('get')->once()->with('mcp_rate_limit:sess-3:list_posts', 0)->andReturn(1); + Cache::shouldReceive('increment')->once()->with('mcp_rate_limit:sess-3:list_posts'); + + $limiter = new ToolRateLimiter; + $limiter->hit('sess-3', 'list_posts'); + $limiter->hit('sess-3', 'list_posts'); +}); diff --git a/php/tests/Feature/Mcp/Support/bootstrap.php b/php/tests/Feature/Mcp/Support/bootstrap.php new file mode 100644 index 0000000..8cc7f9c --- /dev/null +++ b/php/tests/Feature/Mcp/Support/bootstrap.php @@ -0,0 +1,62 @@ +data[$key] ?? $default; + } + } + + class Response + { + public function __construct( + public string $type, + public string $content, + ) { + } + + public static function text(string $content): self + { + return new self('text', $content); + } + } + } + + namespace Laravel\Mcp\Server { + abstract class Resource + { + protected string $description = ''; + } + } + PHP); +} + +function mcpInvokeProtected(object $object, string $method, array $arguments = []): mixed +{ + $reflection = new ReflectionMethod($object, $method); + $reflection->setAccessible(true); + + return $reflection->invokeArgs($object, $arguments); +} diff --git a/php/tests/Feature/Mcp/Transport/McpContextTest.php b/php/tests/Feature/Mcp/Transport/McpContextTest.php new file mode 100644 index 0000000..27ac2ad --- /dev/null +++ b/php/tests/Feature/Mcp/Transport/McpContextTest.php @@ -0,0 +1,49 @@ + 'plan-1']; + $context = new McpContext('sess-1', $plan); + + expect($context->getSessionId())->toBe('sess-1') + ->and($context->getCurrentPlan())->toBe($plan) + ->and($context->hasSession())->toBeTrue() + ->and($context->hasPlan())->toBeTrue(); +}); + +test('McpContext_callbacks_Bad_are_optional_and_can_be_left_unset', function (): void { + $context = new McpContext; + + $context->sendNotification('mcp.progress', ['value' => 50]); + $context->logToSession('noop'); + + expect($context->hasSession())->toBeFalse() + ->and($context->hasPlan())->toBeFalse(); +}); + +test('McpContext_callbacks_Ugly_forward_notifications_and_session_logs_through_the_transport_hooks', function (): void { + $captured = []; + $context = new McpContext( + notificationCallback: function (string $method, array $params) use (&$captured): void { + $captured['notification'] = [$method, $params]; + }, + logCallback: function (string $message, string $type, array $data) use (&$captured): void { + $captured['log'] = [$message, $type, $data]; + }, + ); + + $context->sendNotification('mcp.progress', ['value' => 100]); + $context->logToSession('finished', 'info', ['ok' => true]); + + expect($captured['notification'])->toBe(['mcp.progress', ['value' => 100]]) + ->and($captured['log'])->toBe(['finished', 'info', ['ok' => true]]); +}); diff --git a/php/tests/Feature/Mcp/Transport/McpToolHandlerTest.php b/php/tests/Feature/Mcp/Transport/McpToolHandlerTest.php new file mode 100644 index 0000000..59bebe1 --- /dev/null +++ b/php/tests/Feature/Mcp/Transport/McpToolHandlerTest.php @@ -0,0 +1,65 @@ + 'list_posts', + 'description' => 'List CMS posts', + 'inputSchema' => ['type' => 'object'], + ]; + } + + public function handle(array $args, McpContext $context): array + { + return ['ok' => true]; + } + }; + + expect($handler::schema())->toBe([ + 'name' => 'list_posts', + 'description' => 'List CMS posts', + 'inputSchema' => ['type' => 'object'], + ]); +}); + +test('McpToolHandler_handle_Bad_receives_the_transport_agnostic_context_object', function (): void { + $context = new McpContext('sess-1'); + $handler = new class implements McpToolHandler + { + public static function schema(): array + { + return ['name' => 'ping', 'description' => 'Ping', 'inputSchema' => ['type' => 'object']]; + } + + public function handle(array $args, McpContext $context): array + { + return ['session_id' => $context->getSessionId()]; + } + }; + + expect($handler->handle([], $context)['session_id'])->toBe('sess-1'); +}); + +test('McpToolHandler_interface_Ugly_exposes_exactly_the_two_contract_methods_required_by_the_rfc', function (): void { + $methods = array_map( + static fn (ReflectionMethod $method): string => $method->getName(), + (new ReflectionClass(McpToolHandler::class))->getMethods(), + ); + + expect($methods)->toBe(['schema', 'handle']); +});