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;
+ }
}
}