diff --git a/composer.json b/composer.json index 8536227..40bdf53 100644 --- a/composer.json +++ b/composer.json @@ -46,6 +46,7 @@ "lthn/php-mcp": "self.version" }, "require-dev": { + "dappcore/php-content": "*", "dappcore/php-tenant": "^0.1", "dappcore/php-uptelligence": "*", "laravel/pint": "^1.18", diff --git a/composer.lock b/composer.lock index 20c82bc..2cc82fd 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "046221ccacb791f74f3c92d26bbef72a", + "content-hash": "aa56bd1b5284faed1d81bb8f01ffc13b", "packages": [ { "name": "brick/math", @@ -6302,6 +6302,70 @@ ], "time": "2025-08-20T19:15:30+00:00" }, + { + "name": "dappcore/php-content", + "version": "v0.1.1", + "source": { + "type": "git", + "url": "https://github.com/dAppCore/php-content.git", + "reference": "eee31983226418c3da6bded3833eae239b3ccf6f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dAppCore/php-content/zipball/eee31983226418c3da6bded3833eae239b3ccf6f", + "reference": "eee31983226418c3da6bded3833eae239b3ccf6f", + "shasum": "" + }, + "require": { + "dappcore/php": "*", + "ezyang/htmlpurifier": "^4.17", + "php": "^8.2" + }, + "replace": { + "core/php-content": "self.version" + }, + "require-dev": { + "dappcore/php-tenant": "@dev", + "laravel/pint": "^1.18", + "orchestra/testbench": "^9.0|^10.0", + "pestphp/pest": "^3.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Core\\Mod\\Content\\Boot" + ] + } + }, + "autoload": { + "psr-4": { + "Core\\Mod\\Content\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "EUPL-1.2" + ], + "description": "Content management and headless CMS for Laravel", + "keywords": [ + "cms", + "content", + "headless", + "laravel" + ], + "support": { + "issues": "https://github.com/dAppCore/php-content/issues", + "source": "https://github.com/dAppCore/php-content/tree/v0.1.1" + }, + "funding": [ + { + "url": "https://donate.trees.org/-/NPMMSVUP?member=SWZTDDWH", + "type": "custom" + } + ], + "time": "2026-07-31T16:32:24+00:00" + }, { "name": "dappcore/php-tenant", "version": "v0.1.2", @@ -6474,6 +6538,67 @@ }, "time": "2026-02-07T07:09:04+00:00" }, + { + "name": "ezyang/htmlpurifier", + "version": "v4.19.0", + "source": { + "type": "git", + "url": "https://github.com/ezyang/htmlpurifier.git", + "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/b287d2a16aceffbf6e0295559b39662612b77fcf", + "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf", + "shasum": "" + }, + "require": { + "php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "require-dev": { + "cerdic/css-tidy": "^1.7 || ^2.0", + "simpletest/simpletest": "dev-master" + }, + "suggest": { + "cerdic/css-tidy": "If you want to use the filter 'Filter.ExtractStyleBlocks'.", + "ext-bcmath": "Used for unit conversion and imagecrash protection", + "ext-iconv": "Converts text to and from non-UTF-8 encodings", + "ext-tidy": "Used for pretty-printing HTML" + }, + "type": "library", + "autoload": { + "files": [ + "library/HTMLPurifier.composer.php" + ], + "psr-0": { + "HTMLPurifier": "library/" + }, + "exclude-from-classmap": [ + "/library/HTMLPurifier/Language/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "Edward Z. Yang", + "email": "admin@htmlpurifier.org", + "homepage": "http://ezyang.com" + } + ], + "description": "Standards compliant HTML filter written in PHP", + "homepage": "http://htmlpurifier.org/", + "keywords": [ + "html" + ], + "support": { + "issues": "https://github.com/ezyang/htmlpurifier/issues", + "source": "https://github.com/ezyang/htmlpurifier/tree/v4.19.0" + }, + "time": "2025-10-17T16:34:55+00:00" + }, { "name": "fakerphp/faker", "version": "v1.24.1", diff --git a/php/src/Mcp/Controllers/McpApiController.php b/php/src/Mcp/Controllers/McpApiController.php index f05001a..a112c14 100644 --- a/php/src/Mcp/Controllers/McpApiController.php +++ b/php/src/Mcp/Controllers/McpApiController.php @@ -11,7 +11,6 @@ use Core\Mcp\Resources\Contracts\AgentResourceProvider; use Core\Mcp\Services\McpQuotaService; use Core\Mcp\Services\McpWebhookDispatcher; -use Core\Mod\Agentic\Services\AgentToolRegistry; use Core\Mod\Content\Models\ContentItem; use Core\Tenant\Models\Workspace; use Illuminate\Http\JsonResponse; @@ -430,7 +429,12 @@ protected function agentResourceProvider(): ?object */ protected function executeTool(string $tool, array $arguments, ?ApiKey $apiKey): mixed { - $registryClass = AgentToolRegistry::class; + // A string literal, not ::class with an import: this is a late-bound, + // optional lookup into the consumer, and pint's + // fully_qualified_strict_types fixer rewrites an inline FQCN into a + // top-of-file `use` — which is how a package whose whole point is not + // to import its consumer ended up importing it. + $registryClass = 'Core\\Mod\\Agentic\\Services\\AgentToolRegistry'; if (! app()->bound($registryClass)) { throw new \RuntimeException('AgentToolRegistry not available — is the agentic module installed?'); diff --git a/php/src/Mcp/Exceptions/CircuitOpenException.php b/php/src/Mcp/Exceptions/CircuitOpenException.php index f092600..c3aa1af 100644 --- a/php/src/Mcp/Exceptions/CircuitOpenException.php +++ b/php/src/Mcp/Exceptions/CircuitOpenException.php @@ -1,5 +1,7 @@ call('openbrain', fn () => $client->dispatch(...)); + * } catch (CircuitOpenException $e) { + * return ['error' => "service '{$e->service}' temporarily unavailable"]; + * } */ -class CircuitOpenException extends RuntimeException +final class CircuitOpenException extends RuntimeException { + /** + * Construct an open-circuit exception. When $message is empty, a + * default human-readable message is generated from $service. + * + * Example: + * + * throw new CircuitOpenException('openbrain'); + */ public function __construct( public readonly string $service, string $message = '', ) { - $message = $message ?: sprintf( + parent::__construct($message !== '' ? $message : sprintf( "Service '%s' is temporarily unavailable. Please try again later.", - $service - ); - - parent::__construct($message); + $service, + )); } } diff --git a/php/src/Mcp/Resources/AppConfig.php b/php/src/Mcp/Resources/AppConfig.php index ac623ab..f514347 100644 --- a/php/src/Mcp/Resources/AppConfig.php +++ b/php/src/Mcp/Resources/AppConfig.php @@ -1,24 +1,26 @@ config('app.name'), 'env' => config('app.env'), 'debug' => config('app.debug'), 'url' => config('app.url'), - ]; - - return Response::text(json_encode($config, JSON_PRETTY_PRINT)); + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); } } diff --git a/php/src/Mcp/Resources/ContentResource.php b/php/src/Mcp/Resources/ContentResource.php index 5dc6c67..1901359 100644 --- a/php/src/Mcp/Resources/ContentResource.php +++ b/php/src/Mcp/Resources/ContentResource.php @@ -1,5 +1,9 @@ get('uri', ''); + $uri = (string) $request->get('uri', ''); + $parts = $this->parseUri($uri); - // Parse URI: content://{workspace}/{slug} - if (! str_starts_with($uri, 'content://')) { + if ($parts === null) { return Response::text('Invalid URI format. Expected: content://{workspace}/{slug}'); } - $path = substr($uri, 10); // Remove 'content://' - $parts = explode('/', $path, 2); + [$workspaceIdentifier, $contentIdentifier] = $parts; + $workspace = $this->resolveWorkspace($workspaceIdentifier); - if (count($parts) < 2) { - return Response::text('Invalid URI format. Expected: content://{workspace}/{slug}'); + if ($workspace === null) { + return Response::text(sprintf('Workspace not found: %s', $workspaceIdentifier)); } - [$workspaceSlug, $contentSlug] = $parts; + $item = $this->resolveContentItem($workspace, $contentIdentifier); - // Resolve workspace - $workspace = Workspace::where('slug', $workspaceSlug) - ->orWhere('id', $workspaceSlug) - ->first(); + if ($item === null) { + return Response::text(sprintf('Content not found: %s', $contentIdentifier)); + } + + return Response::text($this->contentToMarkdown($item, $workspace)); + } - if (! $workspace) { - return Response::text("Workspace not found: {$workspaceSlug}"); + public static function list(): array + { + return (new self)->listResources(); + } + + protected function listResources(): array + { + if (! class_exists(Workspace::class) || ! class_exists(ContentItem::class)) { + return []; } - // Find content item - $item = ContentItem::forWorkspace($workspace->id) - ->native() - ->where('slug', $contentSlug) - ->first(); + $resources = []; - if (! $item) { - // Try by ID - if (is_numeric($contentSlug)) { - $item = ContentItem::forWorkspace($workspace->id) - ->native() - ->find($contentSlug); + 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', + ]; } } - if (! $item) { - return Response::text("Content not found: {$contentSlug}"); + return $resources; + } + + protected function parseUri(string $uri): ?array + { + if (! str_starts_with($uri, 'content://')) { + return null; } - // Load relationships - $item->load(['author', 'taxonomies']); + $parts = explode('/', substr($uri, 10), 2); - // Return as markdown with frontmatter - $markdown = $this->contentToMarkdown($item, $workspace); + return count($parts) === 2 ? $parts : null; + } - return Response::text($markdown); + // ?object, matching resolveContentItem: Workspace lives in the tenant + // package, which is optional here (see the class_exists guard), so the + // seam cannot narrow to a type that may not be loaded. + protected function resolveWorkspace(string $identifier): ?object + { + if (! class_exists(Workspace::class)) { + return null; + } + + return Workspace::query() + ->where('slug', $identifier) + ->orWhere('id', $identifier) + ->first(); } - /** - * Convert content item to markdown with frontmatter. - */ - protected function contentToMarkdown(ContentItem $item, Workspace $workspace): string + protected function resolveContentItem(object $workspace, string $identifier): ?object { - $md = "---\n"; - $md .= "title: \"{$item->title}\"\n"; - $md .= "slug: {$item->slug}\n"; - $md .= "workspace: {$workspace->slug}\n"; - $md .= "type: {$item->type}\n"; - $md .= "status: {$item->status}\n"; + if (! class_exists(ContentItem::class)) { + return null; + } - if ($item->author) { - $md .= "author: {$item->author->name}\n"; + $query = ContentItem::query(); + + if (method_exists($query->getModel(), 'scopeForWorkspace')) { + $query->forWorkspace($workspace->id); + } else { + $query->where('workspace_id', $workspace->id); } - $categories = $item->categories->pluck('name')->all(); - if (! empty($categories)) { - $md .= 'categories: ['.implode(', ', $categories)."]\n"; + if (method_exists($query->getModel(), 'scopeNative')) { + $query->native(); } - $tags = $item->tags->pluck('name')->all(); - if (! empty($tags)) { - $md .= 'tags: ['.implode(', ', $tags)."]\n"; + $item = (clone $query)->where('slug', $identifier)->first(); + + if ($item === null && is_numeric($identifier)) { + $item = (clone $query)->find((int) $identifier); } - if ($item->publish_at) { - $md .= 'publish_at: '.$item->publish_at->toIso8601String()."\n"; + if ($item !== null && method_exists($item, 'loadMissing')) { + $item->loadMissing(['author', 'categories', 'tags', 'taxonomies']); } - $md .= 'created_at: '.$item->created_at->toIso8601String()."\n"; - $md .= 'updated_at: '.$item->updated_at->toIso8601String()."\n"; + return $item; + } - if ($item->seo_meta) { - if (isset($item->seo_meta['title'])) { - $md .= "seo_title: \"{$item->seo_meta['title']}\"\n"; - } - if (isset($item->seo_meta['description'])) { - $md .= "seo_description: \"{$item->seo_meta['description']}\"\n"; - } + 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); } - $md .= "---\n\n"; + if (method_exists($query->getModel(), 'scopeNative')) { + $query->native(); + } - // Add excerpt if available - if ($item->excerpt) { - $md .= "> {$item->excerpt}\n\n"; + if (method_exists($query->getModel(), 'scopePublished')) { + $query->published(); + } else { + $query->where('status', 'publish'); } - // Prefer markdown content, fall back to stripping HTML (clean > original) - $content = $item->content_markdown - ?? strip_tags($item->content_html_clean ?? $item->content_html_original ?? ''); - $md .= $content; + return $query + ->orderByDesc('updated_at') + ->limit(50) + ->get(['id', 'slug', 'title', 'type']); + } - return $md; + 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; } - /** - * Get list of available content resources. - * - * This is called when MCP lists available resources. - */ - public static function list(): array + protected function taxonomyNames(object $item, string $relation, string $fallbackType): array { - $resources = []; + $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; + } - // Get all workspaces with content - $workspaces = Workspace::whereHas('contentItems', function ($q) { - $q->native()->where('status', 'publish'); - })->get(); - - foreach ($workspaces as $workspace) { - // Get published content for this workspace - $items = ContentItem::forWorkspace($workspace->id) - ->native() - ->published() - ->orderByDesc('updated_at') - ->limit(50) - ->get(['id', 'slug', 'title', 'type']); - - foreach ($items as $item) { - $resources[] = [ - 'uri' => "content://{$workspace->slug}/{$item->slug}", - 'name' => $item->title, - 'description' => ucfirst($item->type).": {$item->title}", - 'mimeType' => 'text/markdown', - ]; - } + 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; } - return $resources; + $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/src/Mcp/Resources/DatabaseSchema.php b/php/src/Mcp/Resources/DatabaseSchema.php index 023798f..bcc41b4 100644 --- a/php/src/Mcp/Resources/DatabaseSchema.php +++ b/php/src/Mcp/Resources/DatabaseSchema.php @@ -1,27 +1,77 @@ handle($request); + */ public function handle(Request $request): Response { - $schema = collect(DB::select('SHOW TABLES')) - ->mapWithKeys(function ($table) { - $tableName = array_values((array) $table)[0]; - $columns = DB::select("DESCRIBE {$tableName}"); + $schema = []; + + foreach ($this->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 { + $statement = $driver === 'sqlite' + ? 'PRAGMA table_info('.$this->quoteIdentifier($tableName, $driver).')' + : 'DESCRIBE '.$this->quoteIdentifier($tableName, $driver); - return [$tableName => $columns]; - }) - ->toArray(); + return array_map(static fn (object $column): array => (array) $column, DB::select($statement)); + } catch (\Throwable) { + return []; + } + } + + protected function quoteIdentifier(string $identifier, string $driver): string + { + if ($driver === 'sqlite') { + return '"'.str_replace('"', '""', $identifier).'"'; + } - return Response::text(json_encode($schema, JSON_PRETTY_PRINT)); + return '`'.str_replace('`', '``', $identifier).'`'; } } diff --git a/php/src/Mcp/Services/CircuitBreaker.php b/php/src/Mcp/Services/CircuitBreaker.php index f33e405..3e33e20 100644 --- a/php/src/Mcp/Services/CircuitBreaker.php +++ b/php/src/Mcp/Services/CircuitBreaker.php @@ -1,36 +1,33 @@ call('openbrain', fn () => $client->dispatch(...)); */ -class CircuitBreaker +final class CircuitBreaker { - /** - * Cache key prefix for circuit state. - */ protected const CACHE_PREFIX = 'circuit_breaker:'; - /** - * Circuit states. - */ + protected const COUNTER_TTL = 300; + public const STATE_CLOSED = 'closed'; public const STATE_OPEN = 'open'; @@ -38,29 +35,20 @@ class CircuitBreaker public const STATE_HALF_OPEN = 'half_open'; /** - * Default TTL for success/failure counters (seconds). - */ - protected const COUNTER_TTL = 300; - - /** - * Execute a callable with circuit breaker protection. + * Execute $operation under circuit-breaker protection. When the + * circuit is OPEN, $fallback is invoked (or CircuitOpenException + * is thrown if no fallback is supplied). Failures increment the + * service's failure counter and may trip the breaker. * - * @param string $service Service identifier (e.g., 'agentic', 'content') - * @param Closure $operation The operation to execute - * @param Closure|null $fallback Optional fallback when circuit is open - * @return mixed The operation result or fallback value + * Example: * - * @throws CircuitOpenException When circuit is open and no fallback provided - * @throws Throwable When operation fails and circuit records the failure + * $breaker->call('openbrain', $op, fn () => ['cached' => true]); */ public function call(string $service, Closure $operation, ?Closure $fallback = null): mixed { $state = $this->getState($service); - // Fast fail when circuit is open if ($state === self::STATE_OPEN) { - Log::debug("Circuit breaker open for {$service}, failing fast"); - if ($fallback !== null) { return $fallback(); } @@ -68,81 +56,60 @@ public function call(string $service, Closure $operation, ?Closure $fallback = n throw new CircuitOpenException($service); } - // Handle half-open state with trial lock to prevent concurrent trial requests $hasTrialLock = false; + if ($state === self::STATE_HALF_OPEN) { $hasTrialLock = $this->acquireTrialLock($service); if (! $hasTrialLock) { - // Another request is already testing the service, fail fast - Log::debug("Circuit breaker half-open for {$service}, trial in progress, failing fast"); - if ($fallback !== null) { return $fallback(); } - throw new CircuitOpenException($service, "Service '{$service}' is being tested. Please try again shortly."); + throw new CircuitOpenException( + $service, + sprintf("Service '%s' is being tested. Please try again shortly.", $service), + ); } } - // Try the operation try { $result = $operation(); - // Record success and release trial lock if held $this->recordSuccess($service); - if ($hasTrialLock) { - $this->releaseTrialLock($service); - } - return $result; - } catch (Throwable $e) { - // Release trial lock if held - if ($hasTrialLock) { - $this->releaseTrialLock($service); - } + } catch (Throwable $throwable) { + $this->recordFailure($service, $throwable); - // Record failure - $this->recordFailure($service, $e); - - // Check if we should trip the circuit if ($this->shouldTrip($service)) { $this->tripCircuit($service); } - // If fallback provided and this is a recoverable error, use it - if ($fallback !== null && $this->isRecoverableError($e)) { - Log::warning("Circuit breaker using fallback for {$service}", [ - 'error' => $e->getMessage(), - ]); - + if ($fallback !== null && $this->isRecoverableError($throwable)) { return $fallback(); } - throw $e; + throw $throwable; + } finally { + if ($hasTrialLock) { + $this->releaseTrialLock($service); + } } } - /** - * Get the current state of a circuit. - */ public function getState(string $service): string { - $cacheKey = $this->getStateKey($service); - - $state = Cache::get($cacheKey); + $state = Cache::get($this->stateKey($service)); - if ($state === null) { + if (! is_string($state) || $state === '') { return self::STATE_CLOSED; } - // Check if open circuit should transition to half-open if ($state === self::STATE_OPEN) { - $openedAt = Cache::get($this->getOpenedAtKey($service)); - $resetTimeout = $this->getResetTimeout($service); + $openedAt = (int) Cache::get($this->openedAtKey($service), 0); - if ($openedAt && (time() - $openedAt) >= $resetTimeout) { + if ($openedAt > 0 && (time() - $openedAt) >= $this->resetTimeout($service)) { $this->setState($service, self::STATE_HALF_OPEN); return self::STATE_HALF_OPEN; @@ -152,140 +119,88 @@ public function getState(string $service): string return $state; } - /** - * Get circuit statistics for monitoring. - */ public function getStats(string $service): array { return [ 'service' => $service, 'state' => $this->getState($service), - 'failures' => (int) Cache::get($this->getFailureCountKey($service), 0), - 'successes' => (int) Cache::get($this->getSuccessCountKey($service), 0), - 'last_failure' => Cache::get($this->getLastFailureKey($service)), - 'opened_at' => Cache::get($this->getOpenedAtKey($service)), - 'threshold' => $this->getFailureThreshold($service), - 'reset_timeout' => $this->getResetTimeout($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), ]; } - /** - * Manually reset a circuit to closed state. - */ public function reset(string $service): void { $this->setState($service, self::STATE_CLOSED); - Cache::forget($this->getFailureCountKey($service)); - Cache::forget($this->getSuccessCountKey($service)); - Cache::forget($this->getLastFailureKey($service)); - Cache::forget($this->getOpenedAtKey($service)); - - Log::info("Circuit breaker manually reset for {$service}"); + 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)); } - /** - * Check if a service is available (circuit not open). - */ public function isAvailable(string $service): bool { return $this->getState($service) !== self::STATE_OPEN; } - /** - * Record a successful operation. - */ protected function recordSuccess(string $service): void { $state = $this->getState($service); - // Increment success counter with TTL - $this->atomicIncrement($this->getSuccessCountKey($service), self::COUNTER_TTL); + $this->atomicIncrement($this->successCountKey($service), self::COUNTER_TTL); - // If half-open and we got a success, close the circuit if ($state === self::STATE_HALF_OPEN) { $this->closeCircuit($service); } - // Decay failures over time (successful calls reduce failure count) - $this->atomicDecrement($this->getFailureCountKey($service)); + $this->atomicDecrement($this->failureCountKey($service)); } - /** - * Record a failed operation. - */ - protected function recordFailure(string $service, Throwable $e): void + protected function recordFailure(string $service, Throwable $throwable): void { - $failureKey = $this->getFailureCountKey($service); - $lastFailureKey = $this->getLastFailureKey($service); - $window = $this->getFailureWindow($service); + $window = $this->failureWindow($service); + $failures = $this->atomicIncrement($this->failureCountKey($service), $window); - // Atomic increment with TTL refresh using lock - $newCount = $this->atomicIncrement($failureKey, $window); - - // Record last failure details - Cache::put($lastFailureKey, [ - 'message' => $e->getMessage(), - 'class' => get_class($e), + Cache::put($this->lastFailureKey($service), [ + 'message' => $throwable->getMessage(), + 'class' => $throwable::class, 'time' => now()->toIso8601String(), + 'failures' => $failures, ], $window); - - Log::warning("Circuit breaker recorded failure for {$service}", [ - 'error' => $e->getMessage(), - 'failures' => $newCount, - ]); } - /** - * Check if the circuit should trip (open). - */ protected function shouldTrip(string $service): bool { - $failures = (int) Cache::get($this->getFailureCountKey($service), 0); - $threshold = $this->getFailureThreshold($service); - - return $failures >= $threshold; + return (int) Cache::get($this->failureCountKey($service), 0) >= $this->failureThreshold($service); } - /** - * Trip the circuit to open state. - */ protected function tripCircuit(string $service): void { $this->setState($service, self::STATE_OPEN); - Cache::put($this->getOpenedAtKey($service), time(), 86400); // 24h max - - Log::error("Circuit breaker tripped for {$service}", [ - 'failures' => Cache::get($this->getFailureCountKey($service)), - ]); + Cache::put($this->openedAtKey($service), time(), 86400); } - /** - * Close the circuit after successful recovery. - */ protected function closeCircuit(string $service): void { $this->setState($service, self::STATE_CLOSED); - Cache::forget($this->getFailureCountKey($service)); - Cache::forget($this->getOpenedAtKey($service)); - - Log::info("Circuit breaker closed for {$service} after successful recovery"); + Cache::forget($this->failureCountKey($service)); + Cache::forget($this->openedAtKey($service)); + Cache::forget($this->trialLockKey($service)); } - /** - * Set circuit state. - */ protected function setState(string $service, string $state): void { - Cache::put($this->getStateKey($service), $state, 86400); // 24h max + Cache::put($this->stateKey($service), $state, 86400); } - /** - * Check if an exception is recoverable (should use fallback). - */ - protected function isRecoverableError(Throwable $e): bool + protected function isRecoverableError(Throwable $throwable): bool { - // Database connection errors, table not found, etc. - $recoverablePatterns = [ + $patterns = [ 'SQLSTATE', 'Connection refused', 'Table .* doesn\'t exist', @@ -294,10 +209,8 @@ protected function isRecoverableError(Throwable $e): bool 'Too many connections', ]; - $message = $e->getMessage(); - - foreach ($recoverablePatterns as $pattern) { - if (preg_match('/'.$pattern.'/i', $message)) { + foreach ($patterns as $pattern) { + if (preg_match('/'.$pattern.'/i', $throwable->getMessage()) === 1) { return true; } } @@ -305,41 +218,30 @@ protected function isRecoverableError(Throwable $e): bool return false; } - /** - * Get the failure threshold from config. - */ - protected function getFailureThreshold(string $service): int + protected function failureThreshold(string $service): int { - return (int) config("mcp.circuit_breaker.{$service}.threshold", - config('mcp.circuit_breaker.default_threshold', 5) + return (int) config( + sprintf('mcp.circuit_breaker.%s.threshold', $service), + config('mcp.circuit_breaker.default_threshold', 5), ); } - /** - * Get the reset timeout (how long to wait before trying again). - */ - protected function getResetTimeout(string $service): int + protected function resetTimeout(string $service): int { - return (int) config("mcp.circuit_breaker.{$service}.reset_timeout", - config('mcp.circuit_breaker.default_reset_timeout', 60) + return (int) config( + sprintf('mcp.circuit_breaker.%s.reset_timeout', $service), + config('mcp.circuit_breaker.default_reset_timeout', 60), ); } - /** - * Get the failure window (how long failures are counted). - */ - protected function getFailureWindow(string $service): int + protected function failureWindow(string $service): int { - return (int) config("mcp.circuit_breaker.{$service}.failure_window", - config('mcp.circuit_breaker.default_failure_window', 120) + return (int) config( + sprintf('mcp.circuit_breaker.%s.failure_window', $service), + config('mcp.circuit_breaker.default_failure_window', 120), ); } - /** - * Atomically increment a counter with TTL refresh. - * - * Uses a lock to ensure the increment and TTL refresh are atomic. - */ protected function atomicIncrement(string $key, int $ttl): int { $lock = Cache::lock($key.':lock', 5); @@ -347,22 +249,15 @@ protected function atomicIncrement(string $key, int $ttl): int try { $lock->block(3); - $current = (int) Cache::get($key, 0); - $newValue = $current + 1; - Cache::put($key, $newValue, $ttl); + $value = (int) Cache::get($key, 0) + 1; + Cache::put($key, $value, $ttl); - return $newValue; + return $value; } finally { - $lock->release(); + rescue(static fn (): mixed => $lock->release(), report: false); } } - /** - * Atomically decrement a counter (only if positive). - * - * Note: We use COUNTER_TTL as a fallback since Laravel's Cache facade - * doesn't expose remaining TTL. The counter will refresh on activity. - */ protected function atomicDecrement(string $key): int { $lock = Cache::lock($key.':lock', 5); @@ -370,73 +265,52 @@ protected function atomicDecrement(string $key): int try { $lock->block(3); - $current = (int) Cache::get($key, 0); - if ($current > 0) { - $newValue = $current - 1; - Cache::put($key, $newValue, self::COUNTER_TTL); - - return $newValue; - } + $value = max((int) Cache::get($key, 0) - 1, 0); + Cache::put($key, $value, self::COUNTER_TTL); - return 0; + return $value; } finally { - $lock->release(); + rescue(static fn (): mixed => $lock->release(), report: false); } } - /** - * Acquire a trial lock for half-open state. - * - * Only one request can hold the trial lock at a time, preventing - * concurrent trial requests during half-open state. - */ protected function acquireTrialLock(string $service): bool { - $lockKey = $this->getTrialLockKey($service); - - // Try to acquire lock with a short TTL (auto-release if request hangs) - return Cache::add($lockKey, true, 30); + return Cache::add($this->trialLockKey($service), true, 30); } - /** - * Release the trial lock. - */ protected function releaseTrialLock(string $service): void { - Cache::forget($this->getTrialLockKey($service)); + Cache::forget($this->trialLockKey($service)); } - /** - * Get the trial lock cache key. - */ - protected function getTrialLockKey(string $service): string - { - return self::CACHE_PREFIX.$service.':trial_lock'; - } - - // Cache key helpers - protected function getStateKey(string $service): string + protected function stateKey(string $service): string { return self::CACHE_PREFIX.$service.':state'; } - protected function getFailureCountKey(string $service): string + protected function failureCountKey(string $service): string { return self::CACHE_PREFIX.$service.':failures'; } - protected function getSuccessCountKey(string $service): string + protected function successCountKey(string $service): string { return self::CACHE_PREFIX.$service.':successes'; } - protected function getLastFailureKey(string $service): string + protected function lastFailureKey(string $service): string { return self::CACHE_PREFIX.$service.':last_failure'; } - protected function getOpenedAtKey(string $service): string + 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'; + } } diff --git a/php/src/Mcp/Services/DataRedactor.php b/php/src/Mcp/Services/DataRedactor.php index 00f6c63..3f3f462 100644 --- a/php/src/Mcp/Services/DataRedactor.php +++ b/php/src/Mcp/Services/DataRedactor.php @@ -1,20 +1,22 @@ redact(['token' => 'sk_live_secret']); */ -class DataRedactor +final class DataRedactor { - /** - * Keys that should always be fully redacted. - */ + protected const REDACTED = '[REDACTED]'; + protected const SENSITIVE_KEYS = [ 'password', 'passwd', @@ -47,9 +49,6 @@ class DataRedactor 'bank_account', ]; - /** - * Keys containing PII that should be partially redacted. - */ protected const PII_KEYS = [ 'email', 'phone', @@ -71,12 +70,10 @@ class DataRedactor ]; /** - * Replacement string for fully redacted values. - */ - protected const REDACTED = '[REDACTED]'; - - /** - * Redact sensitive data from an array recursively. + * Recursively redact secrets and personal data from a value. + * + * @example + * $safe = $redactor->redact(['authorization' => 'Bearer sk_live_secret']); */ public function redact(mixed $data, int $maxDepth = 10): mixed { @@ -84,6 +81,10 @@ public function redact(mixed $data, int $maxDepth = 10): mixed return '[MAX_DEPTH_EXCEEDED]'; } + if (is_object($data)) { + return $this->redactObject($data, $maxDepth - 1); + } + if (is_array($data)) { return $this->redactArray($data, $maxDepth - 1); } @@ -96,7 +97,108 @@ public function redact(mixed $data, int $maxDepth = 10): mixed } /** - * Redact sensitive values from an array. + * Produce a shortened, redacted preview of a value for dashboards. + * + * @example + * $preview = $redactor->summarize(['email' => 'agent@example.com', 'notes' => 'Long body text']); + */ + public function summarize(mixed $data, int $maxDepth = 3): mixed + { + if ($maxDepth <= 0) { + return '[...]'; + } + + if (is_object($data)) { + return $this->summarizeObject($data, $maxDepth - 1); + } + + 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; + } + + /** + * Redact a structured object after normalising it to array-like data. + * + * @example + * $safe = $this->redactObject((object) ['token' => 'sk_live_secret'], 3); + */ + protected function redactObject(object $data, int $maxDepth): mixed + { + $normalised = $this->normaliseObject($data); + + if (is_object($normalised)) { + return $this->redactObject($normalised, $maxDepth); + } + + if (is_array($normalised)) { + return $this->redactArray($normalised, $maxDepth); + } + + return is_string($normalised) + ? $this->redactString($normalised) + : $normalised; + } + + /** + * Summarise a structured object after normalising it to array-like data. + * + * @example + * $summary = $this->summarizeObject((object) ['email' => 'agent@example.com'], 2); + */ + protected function summarizeObject(object $data, int $maxDepth): mixed + { + $normalised = $this->normaliseObject($data); + + if (is_object($normalised)) { + return $this->summarizeObject($normalised, $maxDepth); + } + + return $this->summarize($normalised, $maxDepth); + } + + /** + * Redact one associative array while preserving non-sensitive keys. + * + * @example + * $safe = $this->redactArray(['password' => 'secret', 'status' => 'ok'], 2); */ protected function redactArray(array $data, int $maxDepth): array { @@ -105,46 +207,39 @@ protected function redactArray(array $data, int $maxDepth): array foreach ($data as $key => $value) { $lowerKey = strtolower((string) $key); - // Check for fully sensitive keys if ($this->isSensitiveKey($lowerKey)) { $result[$key] = self::REDACTED; continue; } - // Check for PII keys - partially redact if ($this->isPiiKey($lowerKey) && is_string($value)) { $result[$key] = $this->partialRedact($value); continue; } - // Recurse into nested arrays (with depth guard) if (is_array($value)) { - if ($maxDepth <= 0) { - $result[$key] = '[MAX_DEPTH_EXCEEDED]'; - } else { - $result[$key] = $this->redactArray($value, $maxDepth - 1); - } - - continue; - } - - // Check string values for embedded sensitive patterns - if (is_string($value)) { - $result[$key] = $this->redactString($value); + $result[$key] = $maxDepth <= 0 + ? '[MAX_DEPTH_EXCEEDED]' + : $this->redactArray($value, $maxDepth - 1); continue; } - $result[$key] = $value; + $result[$key] = is_string($value) + ? $this->redactString($value) + : $value; } return $result; } /** - * Check if a key name indicates sensitive data. + * Decide whether an array key should always be fully redacted. + * + * @example + * $sensitive = $this->isSensitiveKey('api_key'); */ protected function isSensitiveKey(string $key): bool { @@ -158,7 +253,10 @@ protected function isSensitiveKey(string $key): bool } /** - * Check if a key name indicates PII. + * Decide whether an array key should receive partial personal-data redaction. + * + * @example + * $pii = $this->isPiiKey('email'); */ protected function isPiiKey(string $key): bool { @@ -172,57 +270,28 @@ protected function isPiiKey(string $key): bool } /** - * Redact sensitive patterns from a string value. + * Scrub sensitive token formats from a free-form string. + * + * @example + * $safe = $this->redactString('Bearer sk_live_secret'); */ protected function redactString(string $value): string { - // Redact bearer tokens - $value = preg_replace( - '/Bearer\s+[A-Za-z0-9\-_\.]+/i', - 'Bearer '.self::REDACTED, - $value - ) ?? $value; - - // Redact Basic auth - $value = preg_replace( - '/Basic\s+[A-Za-z0-9\+\/=]+/i', - 'Basic '.self::REDACTED, - $value - ) ?? $value; - - // Redact common API key patterns (key_xxx, sk_xxx, pk_xxx) - $value = preg_replace( - '/\b(sk|pk|key|api|token)_[a-zA-Z0-9]{16,}/i', - '$1_'.self::REDACTED, - $value - ) ?? $value; - - // Redact JWT tokens (xxx.xxx.xxx format with base64) - $value = preg_replace( - '/eyJ[a-zA-Z0-9_-]*\.eyJ[a-zA-Z0-9_-]*\.[a-zA-Z0-9_-]*/i', - self::REDACTED, - $value - ) ?? $value; - - // Redact UK National Insurance numbers - $value = preg_replace( - '/[A-Z]{2}\s?\d{2}\s?\d{2}\s?\d{2}\s?[A-Z]/i', - self::REDACTED, - $value - ) ?? $value; - - // Redact credit card numbers (basic pattern) - $value = preg_replace( - '/\b\d{4}[\s\-]?\d{4}[\s\-]?\d{4}[\s\-]?\d{4}\b/', - self::REDACTED, - $value - ) ?? $value; + $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; } /** - * Partially redact a value, showing first and last characters. + * Partially mask a personally identifiable string while leaving a hint. + * + * @example + * $masked = $this->partialRedact('agent@example.com'); */ protected function partialRedact(string $value): string { @@ -236,70 +305,23 @@ protected function partialRedact(string $value): string return substr($value, 0, 2).'***'.substr($value, -1); } - // For longer values, show more context - $showChars = min(3, (int) floor($length / 4)); + $visible = min(3, (int) floor($length / 4)); - return substr($value, 0, $showChars).'***'.substr($value, -$showChars); + return substr($value, 0, $visible).'***'.substr($value, -$visible); } /** - * Create a summary of array data without sensitive information. + * Convert an object into data that can be traversed by the redactor. * - * Useful for result_summary where we want structure info without details. + * @example + * $normalised = $this->normaliseObject((object) ['token' => 'sk_live_secret']); */ - public function summarize(mixed $data, int $maxDepth = 3): mixed + protected function normaliseObject(object $data): mixed { - if ($maxDepth <= 0) { - return '[...]'; - } - - if (is_array($data)) { - $result = []; - $count = count($data); - - // Limit array size in summary - $limit = 10; - $truncated = $count > $limit; - $items = array_slice($data, 0, $limit, true); - - foreach ($items as $key => $value) { - $lowerKey = strtolower((string) $key); - - // Fully redact sensitive keys - if ($this->isSensitiveKey($lowerKey)) { - $result[$key] = self::REDACTED; - - continue; - } - - // Partially redact PII keys - if ($this->isPiiKey($lowerKey) && is_string($value)) { - $result[$key] = $this->partialRedact($value); - - continue; - } - - // Recurse with reduced depth - $result[$key] = $this->summarize($value, $maxDepth - 1); - } - - if ($truncated) { - $result['_truncated'] = '... and '.($count - $limit).' more items'; - } - - return $result; - } - - if (is_string($data)) { - // Redact first, then truncate (prevents leaking sensitive patterns) - $redacted = $this->redactString($data); - if (strlen($redacted) > 100) { - return substr($redacted, 0, 97).'...'; - } - - return $redacted; + if ($data instanceof \JsonSerializable) { + return $data->jsonSerialize(); } - return $data; + return get_object_vars($data); } } diff --git a/php/src/Mcp/Services/McpHealthService.php b/php/src/Mcp/Services/McpHealthService.php index 6a083e9..050592d 100644 --- a/php/src/Mcp/Services/McpHealthService.php +++ b/php/src/Mcp/Services/McpHealthService.php @@ -1,20 +1,24 @@ check('host-hub'); */ +// Not final: loadServerConfig() / executeProcess() are protected seams the suite +// substitutes to health-check without spawning real MCP servers. +// `final` contradicted that and made McpHealthServiceTest a fatal error. class McpHealthService { public const STATUS_ONLINE = 'online'; @@ -25,30 +29,27 @@ class McpHealthService public const STATUS_UNKNOWN = 'unknown'; - /** - * Cache TTL in seconds for health check results. - */ protected int $cacheTtl = 60; - /** - * Timeout in seconds for health check ping. - */ protected int $timeout = 5; /** - * Check health of a specific MCP server. + * Check one MCP server and optionally bypass the cached status. + * + * @example + * $status = $service->check('host-hub', true); */ public function check(string $serverId, bool $forceRefresh = false): array { - $cacheKey = "mcp:health:{$serverId}"; + $cacheKey = sprintf('mcp:health:%s', $serverId); if (! $forceRefresh && Cache::has($cacheKey)) { - return Cache::get($cacheKey); + return (array) Cache::get($cacheKey, []); } $server = $this->loadServerConfig($serverId); - if (! $server) { + if ($server === null) { $result = $this->buildResult(self::STATUS_UNKNOWN, 'Server not found'); Cache::put($cacheKey, $result, $this->cacheTtl); @@ -62,14 +63,16 @@ public function check(string $serverId, bool $forceRefresh = false): array } /** - * Check health of all registered MCP servers. + * Check every registered MCP server and return their status map. + * + * @example + * $statuses = $service->checkAll(); */ public function checkAll(bool $forceRefresh = false): array { - $servers = $this->getRegisteredServers(); $results = []; - foreach ($servers as $serverId) { + foreach ($this->registeredServers() as $serverId) { $results[$serverId] = $this->check($serverId, $forceRefresh); } @@ -77,140 +80,224 @@ public function checkAll(bool $forceRefresh = false): array } /** - * Get cached health status without triggering a check. + * Return a cached health result for one MCP server when present. + * + * @example + * $cached = $service->getCachedStatus('host-hub'); */ public function getCachedStatus(string $serverId): ?array { - return Cache::get("mcp:health:{$serverId}"); + $status = Cache::get(sprintf('mcp:health:%s', $serverId)); + + return is_array($status) ? $status : null; } /** - * Clear cached health status for a server. + * Remove the cached health entry for one MCP server. + * + * @example + * $service->clearCache('host-hub'); */ public function clearCache(string $serverId): void { - Cache::forget("mcp:health:{$serverId}"); + Cache::forget(sprintf('mcp:health:%s', $serverId)); } /** - * Clear all cached health statuses. + * Remove cached health entries for all registered MCP servers. + * + * @example + * $service->clearAllCache(); */ public function clearAllCache(): void { - foreach ($this->getRegisteredServers() as $serverId) { - Cache::forget("mcp:health:{$serverId}"); + foreach ($this->registeredServers() as $serverId) { + $this->clearCache($serverId); } } /** - * Ping a server by sending a minimal MCP request. + * Render an HTML badge for a resolved MCP server status. + * + * @example + * $badge = $service->getStatusBadge(McpHealthService::STATUS_ONLINE); + */ + public function getStatusBadge(string $status): string + { + return match ($status) { + self::STATUS_ONLINE => 'Online', + self::STATUS_OFFLINE => 'Offline', + self::STATUS_DEGRADED => 'Degraded', + default => 'Unknown', + }; + } + + /** + * Map a server status to the dashboard colour token. + * + * @example + * $colour = $service->getStatusColour(McpHealthService::STATUS_DEGRADED); + */ + public function getStatusColour(string $status): string + { + return match ($status) { + self::STATUS_ONLINE => 'green', + self::STATUS_OFFLINE => 'red', + self::STATUS_DEGRADED => 'yellow', + default => 'gray', + }; + } + + /** + * Probe one server definition and derive its health result. + * + * @example + * $result = $this->pingServer(['connection' => ['type' => 'stdio', 'command' => 'php', 'args' => ['artisan', 'mcp:agent-server']]]); */ protected function pingServer(array $server): array { - $connection = $server['connection'] ?? []; - $type = $connection['type'] ?? 'stdio'; + $connection = (array) ($server['connection'] ?? []); + $type = (string) ($connection['type'] ?? 'stdio'); - // Only support stdio for now if ($type !== 'stdio') { - return $this->buildResult( - self::STATUS_UNKNOWN, - "Connection type '{$type}' health check not supported" - ); + return $this->buildResult(self::STATUS_UNKNOWN, sprintf( + "Connection type '%s' health check not supported", + $type, + )); } - $command = $connection['command'] ?? null; - $args = $connection['args'] ?? []; - $cwd = $this->resolveEnvVars($connection['cwd'] ?? getcwd()); - - if (! $command) { + $command = trim($this->resolveEnvVars((string) ($connection['command'] ?? ''))); + if ($command === '') { return $this->buildResult(self::STATUS_OFFLINE, 'No command configured'); } - // Build the MCP initialize request - $initRequest = json_encode([ + $args = array_map( + fn (mixed $value): string => $this->resolveEnvVars((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' => [], + 'capabilities' => new \stdClass, 'clientInfo' => [ 'name' => 'mcp-health-check', 'version' => '1.0.0', ], ], 'id' => 1, - ]); + ], JSON_UNESCAPED_SLASHES); - try { - $startTime = microtime(true); - - // Build full command - $fullCommand = array_merge([$command], $args); - $process = new Process($fullCommand, $cwd); - $process->setInput($initRequest); - $process->setTimeout($this->timeout); - - $process->run(); - - $duration = round((microtime(true) - $startTime) * 1000); - $output = $process->getOutput(); - - // Check for valid JSON-RPC response - if ($process->isSuccessful() && ! empty($output)) { - // Try to parse the response - $lines = explode("\n", trim($output)); - foreach ($lines as $line) { - $response = json_decode($line, true); - if ($response && isset($response['result'])) { - return $this->buildResult( - self::STATUS_ONLINE, - 'Server responding', - [ - 'response_time_ms' => $duration, - 'server_info' => $response['result']['serverInfo'] ?? null, - 'protocol_version' => $response['result']['protocolVersion'] ?? null, - ] - ); - } - } - } + $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); - // Process ran but didn't return expected response - if ($process->isSuccessful()) { - return $this->buildResult( - self::STATUS_DEGRADED, - 'Server started but returned unexpected response', - [ + if (is_array($decoded) && isset($decoded['result'])) { + return $this->buildResult(self::STATUS_ONLINE, 'Server responding', [ 'response_time_ms' => $duration, - 'output' => substr($output, 0, 500), - ] - ); + 'server_info' => $decoded['result']['serverInfo'] ?? null, + 'protocol_version' => $decoded['result']['protocolVersion'] ?? null, + ]); + } } - // Process failed - return $this->buildResult( - self::STATUS_OFFLINE, - 'Server failed to start', - [ - 'exit_code' => $process->getExitCode(), - 'error' => substr($process->getErrorOutput(), 0, 500), - ] - ); - - } catch (\Exception $e) { - Log::warning("MCP health check failed for {$server['id']}", [ - 'error' => $e->getMessage(), + 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, + ]); + } - return $this->buildResult( - self::STATUS_OFFLINE, - 'Health check failed: '.$e->getMessage() - ); + /** + * Execute a server process and capture its output, error, and timing. + * + * @example + * $result = $this->executeProcess(['php', 'artisan', 'mcp:agent-server'], base_path(), "{\"jsonrpc\":\"2.0\",\"method\":\"ping\",\"id\":1}\n"); + */ + 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]); + + $closeCode = proc_close($process); + $exitCode = $timedOut ? 124 : $closeCode; + + 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), + ]; } /** - * Build a health check result array. + * Build a normalised health result payload for dashboard consumers. + * + * @example + * $result = $this->buildResult(self::STATUS_ONLINE, 'Server responding', ['response_time_ms' => 42]); */ protected function buildResult(string $status, string $message, array $extra = []): array { @@ -218,86 +305,65 @@ protected function buildResult(string $status, string $message, array $extra = [ 'status' => $status, 'message' => $message, 'checked_at' => now()->toIso8601String(), - ], $extra); + ], array_filter($extra, static fn (mixed $value): bool => $value !== null)); } /** - * Get list of registered server IDs. + * Return the server identifiers listed in the MCP registry. + * + * @example + * $serverIds = $this->registeredServers(); */ - protected function getRegisteredServers(): array + protected function registeredServers(): array { - $registry = $this->loadRegistry(); + $servers = $this->loadRegistry()['servers'] ?? []; - return collect($registry['servers'] ?? []) - ->pluck('id') - ->all(); + 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 : [], + ))); } /** - * Load the main registry file. + * Load the top-level MCP registry document from resources. + * + * @example + * $registry = $this->loadRegistry(); */ protected function loadRegistry(): array { $path = resource_path('mcp/registry.yaml'); - if (! file_exists($path)) { - return ['servers' => []]; - } - - return Yaml::parseFile($path); + return file_exists($path) ? (array) Yaml::parseFile($path) : ['servers' => []]; } /** - * Load a server's YAML config. + * Load one server definition from the MCP resources directory. + * + * @example + * $server = $this->loadServerConfig('host-hub'); */ - protected function loadServerConfig(string $id): ?array + protected function loadServerConfig(string $serverId): ?array { - $path = resource_path("mcp/servers/{$id}.yaml"); + $path = resource_path(sprintf('mcp/servers/%s.yaml', $serverId)); - if (! file_exists($path)) { - return null; - } - - return Yaml::parseFile($path); + return file_exists($path) ? (array) Yaml::parseFile($path) : null; } /** - * Resolve environment variables in a string. + * Resolve `${NAME}` placeholders inside registry values from the environment. + * + * @example + * $command = $this->resolveEnvVars('${PHP_BINARY:-php}'); */ protected function resolveEnvVars(string $value): string { - return preg_replace_callback('/\$\{([^}]+)\}/', function ($matches) { + return preg_replace_callback('/\$\{([^}]+)\}/', static function (array $matches): string { $parts = explode(':-', $matches[1], 2); - $var = $parts[0]; + $name = $parts[0]; $default = $parts[1] ?? ''; - return env($var, $default); - }, $value); - } - - /** - * Get status badge HTML. - */ - public function getStatusBadge(string $status): string - { - return match ($status) { - self::STATUS_ONLINE => 'Online', - self::STATUS_OFFLINE => 'Offline', - self::STATUS_DEGRADED => 'Degraded', - default => 'Unknown', - }; - } - - /** - * Get status colour class for Tailwind. - */ - public function getStatusColour(string $status): string - { - return match ($status) { - self::STATUS_ONLINE => 'green', - self::STATUS_OFFLINE => 'red', - self::STATUS_DEGRADED => 'yellow', - default => 'gray', - }; + return (string) env($name, $default); + }, $value) ?? $value; } } diff --git a/php/src/Mcp/Services/McpMetricsService.php b/php/src/Mcp/Services/McpMetricsService.php index ce1b391..f8230ad 100644 --- a/php/src/Mcp/Services/McpMetricsService.php +++ b/php/src/Mcp/Services/McpMetricsService.php @@ -1,255 +1,405 @@ getOverview(7); */ -class McpMetricsService +final class McpMetricsService { + protected string $statsTable = 'mcp_tool_call_stats'; + + protected string $callsTable = 'mcp_tool_calls'; + /** - * Get overview metrics for the dashboard. + * Summarise tool activity for the requested reporting window. + * + * @example + * $overview = $service->getOverview(14); */ public function getOverview(int $days = 7): array { - $startDate = now()->subDays($days - 1)->startOfDay(); - - $stats = McpToolCallStat::forDateRange($startDate, now())->get(); - - $totalCalls = $stats->sum('call_count'); - $successCalls = $stats->sum('success_count'); - $errorCalls = $stats->sum('error_count'); + $currentStart = CarbonImmutable::now()->subDays($days - 1)->startOfDay(); + $currentEnd = CarbonImmutable::now()->endOfDay(); + $previousStart = $currentStart->subDays($days); + $previousEnd = $currentStart->subDay()->endOfDay(); - $successRate = $totalCalls > 0 - ? round(($successCalls / $totalCalls) * 100, 1) - : 0; + $current = $this->statsInRange($currentStart, $currentEnd); + $previous = $this->statsInRange($previousStart, $previousEnd); - $avgDuration = $totalCalls > 0 - ? round($stats->sum('total_duration_ms') / $totalCalls, 1) - : 0; - - // Compare to previous period - $previousStart = $startDate->copy()->subDays($days); - $previousStats = McpToolCallStat::forDateRange($previousStart, $startDate->copy()->subDay())->get(); - $previousCalls = $previousStats->sum('call_count'); - - $callsTrend = $previousCalls > 0 - ? round((($totalCalls - $previousCalls) / $previousCalls) * 100, 1) - : 0; + $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' => $successRate, - 'avg_duration_ms' => $avgDuration, - 'calls_trend_percent' => $callsTrend, - 'unique_tools' => $stats->pluck('tool_name')->unique()->count(), - 'unique_servers' => $stats->pluck('server_id')->unique()->count(), + '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, ]; } /** - * Get daily call trend data for charting. + * Return the per-day call trend for the requested reporting window. + * + * @example + * $trend = $service->getDailyTrend(7); */ public function getDailyTrend(int $days = 7): Collection { - $trend = McpToolCallStat::getDailyTrend($days); - - // Fill in missing dates with zeros - $dates = collect(); - for ($i = $days - 1; $i >= 0; $i--) { - $date = now()->subDays($i)->toDateString(); - $existing = $trend->firstWhere('date', $date); - - $dates->push([ + $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' => $existing->total_calls ?? 0, - 'total_success' => $existing->total_success ?? 0, - 'total_errors' => $existing->total_errors ?? 0, - 'success_rate' => $existing->success_rate ?? 0, - ]); + 'total_calls' => $totalCalls, + 'total_success' => $totalSuccess, + 'total_errors' => $totalErrors, + 'success_rate' => $totalCalls > 0 ? round(($totalSuccess / $totalCalls) * 100, 1) : 0.0, + ])); } - return $dates; + return $result; } /** - * Get top tools by call count. + * Return the most-used tools in the requested reporting window. + * + * @example + * $tools = $service->getTopTools(7, 5); */ public function getTopTools(int $days = 7, int $limit = 10): Collection { - return McpToolCallStat::getTopTools($days, $limit); + 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, + ]); + }); } /** - * Get server breakdown. + * Return aggregated call statistics grouped by MCP server. + * + * @example + * $servers = $service->getServerStats(30); */ public function getServerStats(int $days = 7): Collection { - return McpToolCallStat::getServerStats($days); + 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, + ]); + }); } /** - * Get recent tool calls for activity feed. + * Return the latest recorded MCP calls for dashboard activity feeds. + * + * @example + * $calls = $service->getRecentCalls(20); */ public function getRecentCalls(int $limit = 20): Collection { - return McpToolCall::query() + if (! Schema::hasTable($this->callsTable)) { + return collect(); + } + + return DB::table($this->callsTable) ->orderByDesc('created_at') ->limit($limit) ->get() - ->map(function ($call) { - return [ - 'id' => $call->id, - 'server_id' => $call->server_id, - 'tool_name' => $call->tool_name, - 'success' => $call->success, - 'duration' => $call->getDurationForHumans(), - 'duration_ms' => $call->duration_ms, - 'error_message' => $call->error_message, - 'session_id' => $call->session_id, - 'plan_slug' => $call->plan_slug, - 'created_at' => $call->created_at->diffForHumans(), - 'created_at_full' => $call->created_at->toIso8601String(), - ]; + ->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(), + ]); }); } /** - * Get error breakdown. + * Group recent tool failures by tool name and error code. + * + * @example + * $errors = $service->getErrorBreakdown(7); */ public function getErrorBreakdown(int $days = 7): Collection { - return McpToolCall::query() + 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', '>=', now()->subDays($days)) + ->where('created_at', '>=', CarbonImmutable::now()->subDays($days - 1)->startOfDay()->toDateTimeString()) ->groupBy('tool_name', 'error_code') ->orderByDesc('error_count') - ->limit(20) - ->get(); + ->get() + ->map(fn (object $row): Collection => collect([ + 'tool_name' => (string) ($row->tool_name ?? ''), + 'error_code' => $row->error_code, + 'error_count' => (int) $row->error_count, + ])); } /** - * Get tool performance metrics (p50, p95, p99). + * Calculate duration percentiles for successful tool calls. + * + * @example + * $performance = $service->getToolPerformance(7, 10); */ public function getToolPerformance(int $days = 7, int $limit = 10): Collection { - // Get raw call data for percentile calculations - $calls = McpToolCall::query() + 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', '>=', now()->subDays($days)) + ->where('created_at', '>=', CarbonImmutable::now()->subDays($days - 1)->startOfDay()->toDateTimeString()) ->get() ->groupBy('tool_name'); - $performance = collect(); - - foreach ($calls as $toolName => $toolCalls) { - $durations = $toolCalls->pluck('duration_ms')->sort()->values(); + 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(); - if ($count === 0) { - continue; - } - - $performance->push([ + return collect([ 'tool_name' => $toolName, 'call_count' => $count, - 'min_ms' => $durations->first(), - 'max_ms' => $durations->last(), - 'avg_ms' => round($durations->avg(), 1), + '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), ]); - } - - return $performance - ->sortByDesc('call_count') - ->take($limit) - ->values(); + })->sortByDesc('call_count')->take($limit)->values(); } /** - * Get hourly distribution for the last 24 hours. + * Bucket recent tool calls by hour for the last 24 hours. + * + * @example + * $hours = $service->getHourlyDistribution(); */ public function getHourlyDistribution(): Collection { - $hourly = McpToolCall::query() - ->selectRaw('HOUR(created_at) as hour') - ->selectRaw('COUNT(*) as call_count') - ->selectRaw('SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as success_count') - ->where('created_at', '>=', now()->subHours(24)) - ->groupBy('hour') - ->orderBy('hour') - ->get() - ->keyBy('hour'); + $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'); + }); + } - // Fill in missing hours - $result = collect(); - for ($i = 0; $i < 24; $i++) { - $hour = str_pad((string) $i, 2, '0', STR_PAD_LEFT); - $existing = $hourly->get($i); - - $result->push([ - 'hour' => $hour, - 'hour_formatted' => Carbon::createFromTime($i)->format('ga'), - 'call_count' => $existing->call_count ?? 0, - 'success_count' => $existing->success_count ?? 0, - ]); + 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 $result; + return $distribution; } /** - * Get plan activity - which plans are using MCP tools. + * Return activity totals grouped by plan slug for the requested window. + * + * @example + * $plans = $service->getPlanActivity(7, 10); */ public function getPlanActivity(int $days = 7, int $limit = 10): Collection { - return McpToolCall::query() + 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', '>=', now()->subDays($days)) + ->where('created_at', '>=', CarbonImmutable::now()->subDays($days - 1)->startOfDay()->toDateTimeString()) ->groupBy('plan_slug') ->orderByDesc('call_count') ->limit($limit) ->get() - ->map(function ($item) { - $item->success_rate = $item->call_count > 0 - ? round(($item->success_count / $item->call_count) * 100, 1) - : 0; - - return $item; + ->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, + ]); }); } /** - * Calculate percentile from a sorted collection. + * Load raw stats rows for the supplied date range. + * + * @example + * $rows = $this->statsInRange(CarbonImmutable::now()->subDays(6)->startOfDay(), CarbonImmutable::now()->endOfDay()); + */ + 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)); + } + + /** + * Load grouped daily totals for the supplied date range. + * + * @example + * $rows = $this->dailyTrendRows(CarbonImmutable::now()->subDays(6)->startOfDay(), CarbonImmutable::now()->endOfDay()); + */ + 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(); + } + + /** + * Interpolate a percentile value from an ascending list of durations. + * + * @example + * $p95 = $this->percentile(collect([10, 25, 40, 90]), 95); */ protected function percentile(Collection $sortedValues, int $percentile): float { $count = $sortedValues->count(); + if ($count === 0) { - return 0; + return 0.0; } $index = ($percentile / 100) * ($count - 1); @@ -257,11 +407,30 @@ protected function percentile(Collection $sortedValues, int $percentile): float $upper = (int) ceil($index); if ($lower === $upper) { - return $sortedValues[$lower]; + return (float) $sortedValues[$lower]; } $fraction = $index - $lower; - return round($sortedValues[$lower] + ($sortedValues[$upper] - $sortedValues[$lower]) * $fraction, 1); + return round(((float) $sortedValues[$lower]) + (((float) $sortedValues[$upper] - (float) $sortedValues[$lower]) * $fraction), 1); + } + + /** + * Render a millisecond duration using the dashboard display format. + * + * @example + * $label = $this->humanDuration(1250); + */ + 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/src/Mcp/Services/McpWebhookDispatcher.php b/php/src/Mcp/Services/McpWebhookDispatcher.php index 3ec2495..46d32ec 100644 --- a/php/src/Mcp/Services/McpWebhookDispatcher.php +++ b/php/src/Mcp/Services/McpWebhookDispatcher.php @@ -1,22 +1,21 @@ forWorkspace($workspaceId) - ->active() - ->forEvent($eventType) - ->get(); + $endpointClass = $this->endpointModelClass(); - if ($endpoints->isEmpty()) { + if ($endpointClass === null) { return; } + $eventType = 'mcp.tool.executed'; $payload = [ 'event' => $eventType, 'timestamp' => now()->toIso8601String(), @@ -51,78 +45,135 @@ public function dispatchToolExecuted( ], ]; - foreach ($endpoints as $endpoint) { + $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); } } - /** - * Deliver a webhook to an endpoint. - */ - protected function deliverWebhook(WebhookEndpoint $endpoint, array $payload): void + protected function deliverWebhook(object $endpoint, array $payload): void { - $payloadJson = json_encode($payload); - $signature = $endpoint->generateSignature($payloadJson); - - $startTime = microtime(true); + $timestamp = (string) ($payload['timestamp'] ?? now()->toIso8601String()); + $payloadJson = json_encode($payload, JSON_UNESCAPED_SLASHES); + $signature = $this->generateSignature($endpoint, $payloadJson, $timestamp); try { - $response = Http::timeout(10) - ->withHeaders([ - 'Content-Type' => 'application/json', - 'X-Webhook-Signature' => $signature, - 'X-Webhook-Event' => $payload['event'], - 'X-Webhook-Timestamp' => $payload['timestamp'], - ]) - ->withBody($payloadJson, 'application/json') - ->post($endpoint->url); - - $durationMs = (int) ((microtime(true) - $startTime) * 1000); - - // Record delivery - WebhookDelivery::create([ - 'webhook_endpoint_id' => $endpoint->id, - 'event_id' => 'evt_'.uniqid(), - 'event_type' => $payload['event'], - 'payload' => $payload, - 'response_code' => $response->status(), - 'response_body' => substr($response->body(), 0, 1000), - 'status' => $response->successful() ? 'success' : 'failed', - 'attempt' => 1, - 'delivered_at' => $response->successful() ? now() : null, + $response = $this->sendWebhook($endpoint, $payloadJson, [ + 'Content-Type' => 'application/json', + 'X-Webhook-Signature' => $signature, + 'X-Webhook-Event' => (string) $payload['event'], + 'X-Webhook-Timestamp' => $timestamp, ]); - if ($response->successful()) { + $this->recordDelivery($endpoint, $payload, $response->status(), $response->body(), $response->successful()); + + if ($response->successful() && method_exists($endpoint, 'recordSuccess')) { $endpoint->recordSuccess(); - } else { + } + + if (! $response->successful() && method_exists($endpoint, 'recordFailure')) { $endpoint->recordFailure(); - Log::warning('MCP Webhook delivery failed', [ - 'endpoint_id' => $endpoint->id, - 'url' => $endpoint->url, - 'status' => $response->status(), - ]); } - } catch (\Throwable $e) { - $durationMs = (int) ((microtime(true) - $startTime) * 1000); - - WebhookDelivery::create([ - 'webhook_endpoint_id' => $endpoint->id, - 'event_id' => 'evt_'.uniqid(), - 'event_type' => $payload['event'], - 'payload' => $payload, - 'response_code' => 0, - 'response_body' => $e->getMessage(), - 'status' => 'failed', - 'attempt' => 1, - ]); + } catch (\Throwable $throwable) { + $this->recordDelivery($endpoint, $payload, 0, $throwable->getMessage(), false); - $endpoint->recordFailure(); + if (method_exists($endpoint, 'recordFailure')) { + $endpoint->recordFailure(); + } + } + } - Log::error('MCP Webhook delivery error', [ - 'endpoint_id' => $endpoint->id, - 'url' => $endpoint->url, - 'error' => $e->getMessage(), - ]); + 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/src/Mcp/Services/OpenApiGenerator.php b/php/src/Mcp/Services/OpenApiGenerator.php index cd6da93..c7b87de 100644 --- a/php/src/Mcp/Services/OpenApiGenerator.php +++ b/php/src/Mcp/Services/OpenApiGenerator.php @@ -1,20 +1,33 @@ generate(); */ -class OpenApiGenerator +final class OpenApiGenerator { - protected array $registry; + protected array $registry = ['servers' => []]; protected array $servers = []; + /** + * Build the full OpenAPI document from the MCP registry. + * + * @example + * $schema = $generator->generate(); + */ public function generate(): array { $this->loadRegistry(); @@ -30,37 +43,92 @@ public function generate(): array ]; } + /** + * Encode the generated OpenAPI document as formatted JSON. + * + * @example + * $json = $generator->toJson(); + */ public function toJson(): string { - return json_encode($this->generate(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + return (string) json_encode($this->generate(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); } + /** + * Encode the generated OpenAPI document as YAML. + * + * @example + * $yaml = $generator->toYaml(); + */ public function toYaml(): string { return Yaml::dump($this->generate(), 10, 2); } + /** + * Load the MCP registry file that declares available servers. + * + * @example + * $this->loadRegistry(); + */ protected function loadRegistry(): void { $path = resource_path('mcp/registry.yaml'); - $this->registry = file_exists($path) ? Yaml::parseFile($path) : ['servers' => []]; + if (! file_exists($path)) { + $this->registry = ['servers' => []]; + + return; + } + + try { + $this->registry = (array) Yaml::parseFile($path); + } catch (ParseException) { + $this->registry = ['servers' => []]; + } } + /** + * Load server definition files referenced by the MCP registry. + * + * @example + * $this->loadServers(); + */ protected function loadServers(): void { - foreach ($this->registry['servers'] ?? [] as $ref) { - $path = resource_path("mcp/servers/{$ref['id']}.yaml"); - if (file_exists($path)) { - $this->servers[$ref['id']] = Yaml::parseFile($path); + $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)); + if (! file_exists($path)) { + $this->servers[$id] = ['id' => $id, 'name' => $id]; + + continue; + } + + try { + $this->servers[$id] = (array) Yaml::parseFile($path); + } catch (ParseException) { + $this->servers[$id] = ['id' => $id, 'name' => $id]; } } } + /** + * Build the OpenAPI info block for the MCP HTTP API. + * + * @example + * $info = $this->buildInfo(); + */ protected function buildInfo(): array { return [ 'title' => 'Host UK MCP API', - 'description' => 'HTTP API for interacting with Host UK MCP servers. Execute tools, read resources, and discover available capabilities.', + 'description' => 'HTTP API for MCP server discovery, tool execution, and resource reads.', 'version' => '1.0.0', 'contact' => [ 'name' => 'Host UK Support', @@ -69,11 +137,16 @@ protected function buildInfo(): array ], 'license' => [ 'name' => 'Proprietary', - 'url' => 'https://host.uk.com/terms', ], ]; } + /** + * Return the OpenAPI server list for production and local environments. + * + * @example + * $servers = $this->buildServers(); + */ protected function buildServers(): array { return [ @@ -88,214 +161,245 @@ protected function buildServers(): array ]; } + /** + * Build tag definitions for discovery and execution endpoints. + * + * @example + * $tags = $this->buildTags(); + */ protected function buildTags(): array { $tags = [ - [ - 'name' => 'Discovery', - 'description' => 'Server and tool discovery endpoints', - ], - [ - 'name' => 'Execution', - 'description' => 'Tool execution endpoints', - ], + ['name' => 'Discovery', 'description' => 'Server and tool discovery endpoints'], + ['name' => 'Execution', 'description' => 'Tool execution and resource endpoints'], ]; - foreach ($this->servers as $id => $server) { + foreach ($this->servers as $server) { $tags[] = [ - 'name' => $server['name'] ?? $id, - 'description' => $server['tagline'] ?? $server['description'] ?? '', + 'name' => (string) ($server['name'] ?? $server['id'] ?? 'unknown'), + 'description' => (string) ($server['tagline'] ?? $server['description'] ?? ''), ]; } return $tags; } + /** + * Build the path map for the MCP HTTP API. + * + * @example + * $paths = $this->buildPaths(); + */ protected function buildPaths(): array { - $paths = []; - - // Discovery endpoints - $paths['/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', - ], - ], - ], + return [ + '/servers' => [ + 'get' => $this->authenticatedGet( + 'Discovery', + 'List all MCP servers', + 'listServers', + [ + '200' => $this->schemaResponse( + 'List of available servers', + '#/components/schemas/ServerList', + ), ], - ], + ), ], - ]; - - $paths['/servers/{serverId}'] = [ - 'get' => [ - 'tags' => ['Discovery'], - 'summary' => 'Get server details', - 'operationId' => 'getServer', - 'security' => [['bearerAuth' => []], ['apiKeyAuth' => []]], - 'parameters' => [ + '/servers/{serverId}' => [ + 'get' => $this->authenticatedGet( + 'Discovery', + 'Get server details', + 'getServer', [ - 'name' => 'serverId', - 'in' => 'path', - 'required' => true, - 'schema' => ['type' => 'string'], - 'description' => 'Server identifier', - ], - ], - 'responses' => [ - '200' => [ - 'description' => 'Server details with tools and resources', - 'content' => [ - 'application/json' => [ - 'schema' => [ - '$ref' => '#/components/schemas/Server', - ], - ], - ], + '200' => $this->schemaResponse( + 'Server details', + '#/components/schemas/Server', + ), + '404' => ['description' => 'Server not found'], ], - '404' => ['description' => 'Server not found'], - ], + [$this->requiredStringParameter('serverId', 'path')], + ), ], - ]; - - $paths['/servers/{serverId}/tools'] = [ - 'get' => [ - 'tags' => ['Discovery'], - 'summary' => 'List tools for a server', - 'operationId' => 'listServerTools', - 'security' => [['bearerAuth' => []], ['apiKeyAuth' => []]], - 'parameters' => [ + '/servers/{serverId}/tools' => [ + 'get' => $this->authenticatedGet( + 'Discovery', + 'List server tools', + 'listServerTools', [ - 'name' => 'serverId', - 'in' => 'path', - 'required' => true, - 'schema' => ['type' => 'string'], + '200' => $this->schemaResponse( + 'Tool list', + '#/components/schemas/ToolList', + ), ], - ], - 'responses' => [ - '200' => [ - 'description' => 'List of tools', - 'content' => [ - 'application/json' => [ - 'schema' => [ - '$ref' => '#/components/schemas/ToolList', - ], - ], - ], + [$this->requiredStringParameter('serverId', 'path')], + ), + ], + '/servers/{serverId}/resources' => [ + 'get' => $this->authenticatedGet( + 'Discovery', + 'List server resources', + 'listServerResources', + [ + '200' => $this->schemaResponse( + 'Resource list', + '#/components/schemas/ResourceList', + ), ], - ], + [$this->requiredStringParameter('serverId', 'path')], + ), ], - ]; - - $paths['/servers/{serverId}/resources'] = [ - 'get' => [ - 'tags' => ['Discovery'], - 'summary' => 'List resources for a server', - 'operationId' => 'listServerResources', - 'security' => [['bearerAuth' => []], ['apiKeyAuth' => []]], - 'parameters' => [ + '/tools/call' => [ + 'post' => $this->authenticatedPost( + 'Execution', + 'Execute an MCP tool', + 'callTool', + '#/components/schemas/ToolCallRequest', [ - 'name' => 'serverId', - 'in' => 'path', - 'required' => true, - 'schema' => ['type' => 'string'], + '200' => $this->schemaResponse( + 'Tool executed successfully', + '#/components/schemas/ToolCallResponse', + ), + '400' => ['description' => 'Invalid request'], + '401' => ['description' => 'Unauthorized'], + '404' => ['description' => 'Server or tool not found'], + '500' => ['description' => 'Tool execution error'], ], - ], - 'responses' => [ - '200' => [ - 'description' => 'List of resources', - 'content' => [ - 'application/json' => [ - 'schema' => [ - '$ref' => '#/components/schemas/ResourceList', - ], - ], - ], + ), + ], + '/resources' => [ + 'get' => $this->authenticatedGet( + 'Execution', + 'Read a resource', + 'readResource', + [ + '200' => $this->schemaResponse( + 'Resource payload', + '#/components/schemas/ResourceResponse', + ), ], - ], + [$this->requiredStringParameter('uri', 'query')], + ), ], ]; + } - // Execution endpoint - $paths['/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'], - ], - ], + /** + * Build a secured GET operation definition for the OpenAPI document. + * + * @example + * $operation = $this->authenticatedGet('Discovery', 'List all MCP servers', 'listServers', ['200' => ['description' => 'OK']]); + */ + protected function authenticatedGet( + string $tag, + string $summary, + string $operationId, + array $responses, + array $parameters = [], + ): array { + $operation = [ + 'tags' => [$tag], + 'summary' => $summary, + 'operationId' => $operationId, + 'security' => $this->securityRequirements(), + 'responses' => $responses, ]; - // Resource endpoint - $paths['/resources/{uri}'] = [ - 'get' => [ - 'tags' => ['Execution'], - 'summary' => 'Read a resource', - 'operationId' => 'readResource', - 'security' => [['bearerAuth' => []], ['apiKeyAuth' => []]], - 'parameters' => [ - [ - 'name' => 'uri', - 'in' => 'path', - 'required' => true, - 'schema' => ['type' => 'string'], - 'description' => 'Resource URI (server://path)', - ], - ], - 'responses' => [ - '200' => [ - 'description' => 'Resource content', - 'content' => [ - 'application/json' => [ - 'schema' => [ - '$ref' => '#/components/schemas/ResourceResponse', - ], - ], - ], - ], - ], + if ($parameters !== []) { + $operation['parameters'] = $parameters; + } + + return $operation; + } + + /** + * Build a secured POST operation definition for the OpenAPI document. + * + * @example + * $operation = $this->authenticatedPost('Execution', 'Execute an MCP tool', 'callTool', '#/components/schemas/ToolCallRequest', ['200' => ['description' => 'OK']]); + */ + protected function authenticatedPost( + string $tag, + string $summary, + string $operationId, + string $requestSchemaRef, + array $responses, + ): array { + return [ + 'tags' => [$tag], + 'summary' => $summary, + 'operationId' => $operationId, + 'security' => $this->securityRequirements(), + 'requestBody' => [ + 'required' => true, + 'content' => $this->jsonSchemaContent($requestSchemaRef), ], + 'responses' => $responses, + ]; + } + + /** + * Return the supported authentication schemes for every endpoint. + * + * @example + * $security = $this->securityRequirements(); + */ + protected function securityRequirements(): array + { + return [['bearerAuth' => []], ['apiKeyAuth' => []]]; + } + + /** + * Build a required string parameter schema for a path or query field. + * + * @example + * $parameter = $this->requiredStringParameter('serverId', 'path'); + */ + protected function requiredStringParameter(string $name, string $location): array + { + return [ + 'name' => $name, + 'in' => $location, + 'required' => true, + 'schema' => ['type' => 'string'], ]; + } - return $paths; + /** + * Build a response definition that points to a shared schema reference. + * + * @example + * $response = $this->schemaResponse('Server details', '#/components/schemas/Server'); + */ + protected function schemaResponse(string $description, string $schemaRef): array + { + return [ + 'description' => $description, + 'content' => $this->jsonSchemaContent($schemaRef), + ]; + } + + /** + * Build an `application/json` content block for a schema reference. + * + * @example + * $content = $this->jsonSchemaContent('#/components/schemas/ToolCallResponse'); + */ + protected function jsonSchemaContent(string $schemaRef): array + { + return [ + 'application/json' => [ + 'schema' => ['$ref' => $schemaRef], + ], + ]; } + /** + * Build shared schema and security component definitions. + * + * @example + * $components = $this->buildComponents(); + */ protected function buildComponents(): array { return [ @@ -303,147 +407,104 @@ protected function buildComponents(): array 'bearerAuth' => [ 'type' => 'http', 'scheme' => 'bearer', - 'description' => 'API key in Bearer format: hk_xxx_yyy', + 'description' => 'API key in bearer format, e.g. hk_xxx_yyy', ], 'apiKeyAuth' => [ 'type' => 'apiKey', 'in' => 'header', 'name' => 'X-API-Key', - 'description' => 'API key header', ], ], - 'schemas' => $this->buildSchemas(), - ]; - } - - protected function buildSchemas(): array - { - $schemas = [ - 'ServerList' => [ - 'type' => 'object', - 'properties' => [ - 'servers' => [ - 'type' => 'array', - 'items' => ['$ref' => '#/components/schemas/ServerSummary'], + 'schemas' => [ + 'ServerList' => [ + 'type' => 'object', + 'properties' => [ + 'servers' => ['type' => 'array', 'items' => ['$ref' => '#/components/schemas/ServerSummary']], + 'count' => ['type' => 'integer'], ], - 'count' => ['type' => 'integer'], - ], - ], - 'ServerSummary' => [ - 'type' => 'object', - 'properties' => [ - 'id' => ['type' => 'string'], - 'name' => ['type' => 'string'], - 'tagline' => ['type' => 'string'], - 'status' => ['type' => 'string', 'enum' => ['available', 'beta', 'deprecated']], - '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'], + 'ServerSummary' => [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'string'], + 'name' => ['type' => 'string'], + 'tagline' => ['type' => 'string'], + 'tool_count' => ['type' => 'integer'], + 'resource_count' => ['type' => 'integer'], ], ], - ], - 'Tool' => [ - 'type' => 'object', - 'properties' => [ - 'name' => ['type' => 'string'], - 'description' => ['type' => 'string'], - 'inputSchema' => [ - 'type' => 'object', - 'additionalProperties' => true, + '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']], ], ], - ], - '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'], + 'Tool' => [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string'], + 'description' => ['type' => 'string'], + 'inputSchema' => ['type' => 'object', 'additionalProperties' => true], ], - 'count' => ['type' => 'integer'], ], - ], - 'ToolCallRequest' => [ - 'type' => 'object', - 'required' => ['server', 'tool'], - 'properties' => [ - 'server' => [ - 'type' => 'string', - 'description' => 'Server ID', + 'Resource' => [ + 'type' => 'object', + 'properties' => [ + 'uri' => ['type' => 'string'], + 'name' => ['type' => 'string'], + 'description' => ['type' => 'string'], + 'mimeType' => ['type' => 'string'], ], - 'tool' => [ - 'type' => 'string', - 'description' => 'Tool name', + ], + 'ToolList' => [ + 'type' => 'object', + 'properties' => [ + 'server' => ['type' => 'string'], + 'tools' => ['type' => 'array', 'items' => ['$ref' => '#/components/schemas/Tool']], + 'count' => ['type' => 'integer'], ], - 'arguments' => [ - 'type' => 'object', - 'description' => 'Tool arguments', - 'additionalProperties' => true, + ], + '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, + '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'], ], - 'duration_ms' => ['type' => 'integer'], - 'error' => ['type' => 'string'], ], - ], - 'ResourceResponse' => [ - 'type' => 'object', - 'properties' => [ - 'uri' => ['type' => 'string'], - 'content' => [ - 'type' => 'object', - 'additionalProperties' => true, + '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'], + 'ResourceList' => [ + 'type' => 'object', + 'properties' => [ + 'server' => ['type' => 'string'], + 'resources' => ['type' => 'array', 'items' => ['$ref' => '#/components/schemas/Resource']], + 'count' => ['type' => 'integer'], ], - 'count' => ['type' => 'integer'], ], ], ]; - - return $schemas; } } diff --git a/php/src/Mcp/Services/ToolRateLimiter.php b/php/src/Mcp/Services/ToolRateLimiter.php index f9e2755..4668e6e 100644 --- a/php/src/Mcp/Services/ToolRateLimiter.php +++ b/php/src/Mcp/Services/ToolRateLimiter.php @@ -1,5 +1,7 @@ check($apiKey, 'agent.brain.recall'); + * if ($result['limited']) { + * return response()->json(['error' => 'rate_limited'], 429); + * } + * $rl->hit($apiKey, 'agent.brain.recall'); */ -class ToolRateLimiter +final class ToolRateLimiter { - /** - * Cache key prefix for rate limit tracking. - */ protected const CACHE_PREFIX = 'mcp_rate_limit:'; /** - * Check if a tool call should be rate limited. + * Inspect the rate-limit state for a (caller, tool) pair without + * incrementing it. Returns ['limited' => bool, 'remaining' => int, + * 'retry_after' => ?int]. + * + * Example: * - * @param string $identifier Session ID, API key, or other unique identifier - * @param string $toolName The tool being called - * @return array{limited: bool, remaining: int, retry_after: int|null} + * ['limited' => false, 'remaining' => 99, 'retry_after' => null] */ public function check(string $identifier, string $toolName): array { @@ -32,14 +42,13 @@ public function check(string $identifier, string $toolName): array return ['limited' => false, 'remaining' => PHP_INT_MAX, 'retry_after' => null]; } - $limit = $this->getLimitForTool($toolName); - $decaySeconds = config('mcp.rate_limiting.decay_seconds', 60); - $cacheKey = $this->getCacheKey($identifier, $toolName); - + $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 = Cache::ttl($cacheKey); + $ttl = $this->ttl($cacheKey, $decaySeconds); return [ 'limited' => true, @@ -50,16 +59,16 @@ public function check(string $identifier, string $toolName): array return [ 'limited' => false, - 'remaining' => $limit - $current - 1, + 'remaining' => max($limit - $current - 1, 0), 'retry_after' => null, ]; } /** - * Record a tool call against the rate limit. + * Increment the rate-limit counter for a (caller, tool) pair. * - * @param string $identifier Session ID, API key, or other unique identifier - * @param string $toolName The tool being called + * @example + * $rl->hit($apiKey, 'agent.brain.recall'); */ public function hit(string $identifier, string $toolName): void { @@ -67,78 +76,99 @@ public function hit(string $identifier, string $toolName): void return; } - $decaySeconds = config('mcp.rate_limiting.decay_seconds', 60); - $cacheKey = $this->getCacheKey($identifier, $toolName); + $cacheKey = $this->cacheKey($identifier, $toolName); + $decaySeconds = (int) config('mcp.rate_limiting.decay_seconds', 60); - $current = (int) Cache::get($cacheKey, 0); - - if ($current === 0) { - // First call - set with expiration - Cache::put($cacheKey, 1, $decaySeconds); - } else { - // Increment without resetting TTL - Cache::increment($cacheKey); + if (Cache::add($cacheKey, 1, $decaySeconds)) { + return; } + + Cache::increment($cacheKey); } /** - * Clear rate limit for an identifier. + * Clear one rate-limit bucket or every configured bucket for a caller. * - * @param string $identifier Session ID, API key, or other unique identifier - * @param string|null $toolName Specific tool, or null to clear all + * @example + * $rl->clear($apiKey, 'agent.brain.recall'); */ public function clear(string $identifier, ?string $toolName = null): void { if ($toolName !== null) { - Cache::forget($this->getCacheKey($identifier, $toolName)); - } else { - // Clear all tool rate limits for this identifier (requires knowing tools) - // For now, just clear the specific key pattern - Cache::forget($this->getCacheKey($identifier, '*')); + 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, '*')); } /** - * Get the rate limit for a specific tool. + * Return the current limit, remaining calls, and reset timestamp. + * + * @example + * $status = $rl->getStatus($apiKey, 'agent.brain.recall'); */ - protected function getLimitForTool(string $toolName): int + public function getStatus(string $identifier, string $toolName): array { - // Check for tool-specific limit - $perToolLimits = config('mcp.rate_limiting.per_tool', []); + $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)); - if (isset($perToolLimits[$toolName])) { - return (int) $perToolLimits[$toolName]; + return [ + 'limit' => $limit, + 'remaining' => max($limit - $current, 0), + 'reset_at' => $ttl > 0 ? now()->addSeconds($ttl)->toIso8601String() : null, + ]; + } + + /** + * Resolve the configured call limit for one tool name. + * + * @example + * $limit = $this->limitForTool('agent.brain.recall'); + */ + protected function limitForTool(string $toolName): int + { + $perTool = (array) config('mcp.rate_limiting.per_tool', []); + + if (array_key_exists($toolName, $perTool)) { + return (int) $perTool[$toolName]; } - // Use default limit return (int) config('mcp.rate_limiting.calls_per_minute', 60); } /** - * Generate cache key for rate limiting. + * Build the cache key used for one caller and tool bucket. + * + * @example + * $key = $this->cacheKey($apiKey, 'agent.brain.recall'); */ - protected function getCacheKey(string $identifier, string $toolName): string + protected function cacheKey(string $identifier, string $toolName): string { - // Use general key for overall rate limiting return self::CACHE_PREFIX.$identifier.':'.$toolName; } /** - * Get rate limit status for reporting. + * Read the remaining time-to-live for a cached rate-limit bucket. * - * @return array{limit: int, remaining: int, reset_at: string|null} + * @example + * $ttl = $this->ttl($this->cacheKey($apiKey, 'agent.brain.recall'), 60); */ - public function getStatus(string $identifier, string $toolName): array + protected function ttl(string $cacheKey, int $default): int { - $limit = $this->getLimitForTool($toolName); - $cacheKey = $this->getCacheKey($identifier, $toolName); - $current = (int) Cache::get($cacheKey, 0); - $ttl = Cache::ttl($cacheKey); + try { + $ttl = Cache::ttl($cacheKey); - return [ - 'limit' => $limit, - 'remaining' => max(0, $limit - $current), - 'reset_at' => $ttl > 0 ? now()->addSeconds($ttl)->toIso8601String() : null, - ]; + return is_int($ttl) ? $ttl : $default; + } catch (\Throwable) { + return $default; + } } }