feat(mcp): implement extended RFC services + transport (#842)

Additive-only — no existing files modified.

Services (php/Mcp/Services/):
- CircuitBreaker (3-state, Cache::add trial lock)
- DataRedactor (28 sensitive + 16 PII keys, partial-redact algorithm)
- McpHealthService (YAML registry + JSON-RPC stdio ping protocolVersion 2024-11-05)
- McpMetricsService (p50/p95/p99 linear interpolation)
- McpWebhookDispatcher (mcp.tool.executed → WebhookEndpoints)
- OpenApiGenerator (OpenAPI 3.0.3)
- ToolRateLimiter (Cache::put first, Cache::increment after — no reset)
- AgentSessionService (php/Mod/Mcp/Services/ namespace per spec)

Transport (php/Mcp/Transport/):
- McpContext (transport-agnostic callbacks)
- Contracts/McpToolHandler interface

Resources (php/Mcp/Resources/):
- AppConfig, ContentResource, DatabaseSchema

Config: php/resources/mcp/registry.yaml.
Pest Feature tests _Good/_Bad/_Ugly per AX-10 for each new class.

Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=842
This commit is contained in:
Snider 2026-04-25 05:50:16 +01:00
parent dffdad8418
commit 91551dec9b
28 changed files with 3456 additions and 0 deletions

View file

@ -0,0 +1,26 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
namespace Core\Mcp\Resources;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Resource;
final class AppConfig extends Resource
{
protected string $description = 'Application configuration for Host Hub';
public function handle(Request $request): Response
{
return Response::text((string) json_encode([
'name' => config('app.name'),
'env' => config('app.env'),
'debug' => config('app.debug'),
'url' => config('app.url'),
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
}
}

View file

@ -0,0 +1,222 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
namespace Core\Mcp\Resources;
use Core\Tenant\Models\Workspace;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Resource;
use Mod\Content\Models\ContentItem;
use Symfony\Component\Yaml\Yaml;
final class ContentResource extends Resource
{
protected string $description = 'Content items from the CMS - returns markdown for AI context';
public function handle(Request $request): Response
{
$uri = (string) $request->get('uri', '');
$parts = $this->parseUri($uri);
if ($parts === null) {
return Response::text('Invalid URI format. Expected: content://{workspace}/{slug}');
}
[$workspaceIdentifier, $contentIdentifier] = $parts;
$workspace = $this->resolveWorkspace($workspaceIdentifier);
if ($workspace === null) {
return Response::text(sprintf('Workspace not found: %s', $workspaceIdentifier));
}
$item = $this->resolveContentItem($workspace, $contentIdentifier);
if ($item === null) {
return Response::text(sprintf('Content not found: %s', $contentIdentifier));
}
return Response::text($this->contentToMarkdown($item, $workspace));
}
public static function list(): array
{
return (new self)->listResources();
}
protected function listResources(): array
{
if (! class_exists(Workspace::class) || ! class_exists(ContentItem::class)) {
return [];
}
$resources = [];
foreach (Workspace::query()->get(['id', 'slug']) as $workspace) {
foreach ($this->publishedItemsForWorkspace($workspace)->take(50) as $item) {
$resources[] = [
'uri' => sprintf('content://%s/%s', $workspace->slug, $item->slug),
'name' => (string) $item->title,
'description' => sprintf('%s: %s', ucfirst((string) ($item->type ?? 'content')), (string) $item->title),
'mimeType' => 'text/markdown',
];
}
}
return $resources;
}
protected function parseUri(string $uri): ?array
{
if (! str_starts_with($uri, 'content://')) {
return null;
}
$parts = explode('/', substr($uri, 10), 2);
return count($parts) === 2 ? $parts : null;
}
protected function resolveWorkspace(string $identifier): ?Workspace
{
if (! class_exists(Workspace::class)) {
return null;
}
return Workspace::query()
->where('slug', $identifier)
->orWhere('id', $identifier)
->first();
}
protected function resolveContentItem(object $workspace, string $identifier): ?object
{
if (! class_exists(ContentItem::class)) {
return null;
}
$query = ContentItem::query();
if (method_exists($query->getModel(), 'scopeForWorkspace')) {
$query->forWorkspace($workspace->id);
} else {
$query->where('workspace_id', $workspace->id);
}
if (method_exists($query->getModel(), 'scopeNative')) {
$query->native();
}
$item = (clone $query)->where('slug', $identifier)->first();
if ($item === null && is_numeric($identifier)) {
$item = (clone $query)->find((int) $identifier);
}
if ($item !== null && method_exists($item, 'loadMissing')) {
$item->loadMissing(['author', 'categories', 'tags', 'taxonomies']);
}
return $item;
}
protected function publishedItemsForWorkspace(object $workspace)
{
$query = ContentItem::query();
if (method_exists($query->getModel(), 'scopeForWorkspace')) {
$query->forWorkspace($workspace->id);
} else {
$query->where('workspace_id', $workspace->id);
}
if (method_exists($query->getModel(), 'scopeNative')) {
$query->native();
}
if (method_exists($query->getModel(), 'scopePublished')) {
$query->published();
} else {
$query->where('status', 'publish');
}
return $query
->orderByDesc('updated_at')
->limit(50)
->get(['id', 'slug', 'title', 'type']);
}
protected function contentToMarkdown(object $item, object $workspace): string
{
$frontMatter = [
'title' => (string) ($item->title ?? ''),
'slug' => (string) ($item->slug ?? ''),
'workspace' => (string) ($workspace->slug ?? $workspace->id ?? ''),
'type' => (string) ($item->type ?? ''),
'status' => (string) ($item->status ?? ''),
'author' => data_get($item, 'author.name'),
'categories' => $this->taxonomyNames($item, 'categories', 'category'),
'tags' => $this->taxonomyNames($item, 'tags', 'tag'),
'publish_at' => $item->publish_at?->toIso8601String(),
'created_at' => $item->created_at?->toIso8601String(),
'updated_at' => $item->updated_at?->toIso8601String(),
'seo_title' => data_get($item, 'seo_meta.title') ?? data_get($item, 'seo_title'),
'seo_description' => data_get($item, 'seo_meta.description') ?? data_get($item, 'seo_description'),
];
$frontMatter = array_filter($frontMatter, static fn (mixed $value): bool => $value !== null && $value !== []);
$markdown = "---\n".Yaml::dump($frontMatter, 3, 2)."---\n\n";
$excerpt = trim((string) ($item->excerpt ?? ''));
if ($excerpt !== '') {
$markdown .= collect(preg_split('/\R/', $excerpt) ?: [])
->map(static fn (string $line): string => '> '.$line)
->implode("\n")."\n\n";
}
$markdown .= $this->contentBody($item);
return $markdown;
}
protected function taxonomyNames(object $item, string $relation, string $fallbackType): array
{
$names = collect(data_get($item, $relation, []))
->map(static fn (mixed $taxonomy): ?string => is_object($taxonomy) ? ($taxonomy->name ?? null) : null)
->filter()
->values()
->all();
if ($names !== []) {
return $names;
}
return collect(data_get($item, 'taxonomies', []))
->filter(static fn (mixed $taxonomy): bool => is_object($taxonomy) && (($taxonomy->type ?? null) === $fallbackType || ($taxonomy->taxonomy ?? null) === $fallbackType))
->map(static fn (object $taxonomy): ?string => $taxonomy->name ?? null)
->filter()
->values()
->all();
}
protected function contentBody(object $item): string
{
$markdown = trim((string) ($item->content_markdown ?? ''));
if ($markdown !== '') {
return $markdown;
}
$cleanHtml = trim((string) ($item->content_html_clean ?? ''));
if ($cleanHtml !== '') {
return trim(strip_tags($cleanHtml));
}
return trim(strip_tags((string) ($item->content_html_original ?? '')));
}
}

View file

@ -0,0 +1,54 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
namespace Core\Mcp\Resources;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Resource;
final class DatabaseSchema extends Resource
{
protected string $description = 'Database schema information for Host Hub';
public function handle(Request $request): Response
{
$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 {
return array_map(static fn (object $column): array => (array) $column, DB::select(sprintf(
$driver === 'sqlite' ? 'PRAGMA table_info("%s")' : 'DESCRIBE `%s`',
$tableName,
)));
} catch (\Throwable) {
return [];
}
}
}

View file

@ -0,0 +1,314 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
namespace Core\Mcp\Services {
use Closure;
use Core\Mcp\Exceptions\CircuitOpenException;
use Illuminate\Support\Facades\Cache;
use Throwable;
final class CircuitBreaker
{
protected const CACHE_PREFIX = 'circuit_breaker:';
protected const COUNTER_TTL = 300;
public const STATE_CLOSED = 'closed';
public const STATE_OPEN = 'open';
public const STATE_HALF_OPEN = 'half_open';
public function call(string $service, Closure $operation, ?Closure $fallback = null): mixed
{
$state = $this->getState($service);
if ($state === self::STATE_OPEN) {
if ($fallback !== null) {
return $fallback();
}
throw new CircuitOpenException($service);
}
$hasTrialLock = false;
if ($state === self::STATE_HALF_OPEN) {
$hasTrialLock = $this->acquireTrialLock($service);
if (! $hasTrialLock) {
if ($fallback !== null) {
return $fallback();
}
throw new CircuitOpenException(
$service,
sprintf("Service '%s' is being tested. Please try again shortly.", $service),
);
}
}
try {
$result = $operation();
$this->recordSuccess($service);
return $result;
} catch (Throwable $throwable) {
$this->recordFailure($service, $throwable);
if ($this->shouldTrip($service)) {
$this->tripCircuit($service);
}
if ($fallback !== null && $this->isRecoverableError($throwable)) {
return $fallback();
}
throw $throwable;
} finally {
if ($hasTrialLock) {
$this->releaseTrialLock($service);
}
}
}
public function getState(string $service): string
{
$state = Cache::get($this->stateKey($service));
if (! is_string($state) || $state === '') {
return self::STATE_CLOSED;
}
if ($state === self::STATE_OPEN) {
$openedAt = (int) Cache::get($this->openedAtKey($service), 0);
if ($openedAt > 0 && (time() - $openedAt) >= $this->resetTimeout($service)) {
$this->setState($service, self::STATE_HALF_OPEN);
return self::STATE_HALF_OPEN;
}
}
return $state;
}
public function getStats(string $service): array
{
return [
'service' => $service,
'state' => $this->getState($service),
'failures' => (int) Cache::get($this->failureCountKey($service), 0),
'successes' => (int) Cache::get($this->successCountKey($service), 0),
'last_failure' => Cache::get($this->lastFailureKey($service)),
'opened_at' => Cache::get($this->openedAtKey($service)),
'threshold' => $this->failureThreshold($service),
'reset_timeout' => $this->resetTimeout($service),
];
}
public function reset(string $service): void
{
$this->setState($service, self::STATE_CLOSED);
Cache::forget($this->failureCountKey($service));
Cache::forget($this->successCountKey($service));
Cache::forget($this->lastFailureKey($service));
Cache::forget($this->openedAtKey($service));
Cache::forget($this->trialLockKey($service));
}
public function isAvailable(string $service): bool
{
return $this->getState($service) !== self::STATE_OPEN;
}
protected function recordSuccess(string $service): void
{
$state = $this->getState($service);
$this->atomicIncrement($this->successCountKey($service), self::COUNTER_TTL);
if ($state === self::STATE_HALF_OPEN) {
$this->closeCircuit($service);
}
$this->atomicDecrement($this->failureCountKey($service));
}
protected function recordFailure(string $service, Throwable $throwable): void
{
$window = $this->failureWindow($service);
$failures = $this->atomicIncrement($this->failureCountKey($service), $window);
Cache::put($this->lastFailureKey($service), [
'message' => $throwable->getMessage(),
'class' => $throwable::class,
'time' => now()->toIso8601String(),
'failures' => $failures,
], $window);
}
protected function shouldTrip(string $service): bool
{
return (int) Cache::get($this->failureCountKey($service), 0) >= $this->failureThreshold($service);
}
protected function tripCircuit(string $service): void
{
$this->setState($service, self::STATE_OPEN);
Cache::put($this->openedAtKey($service), time(), 86400);
}
protected function closeCircuit(string $service): void
{
$this->setState($service, self::STATE_CLOSED);
Cache::forget($this->failureCountKey($service));
Cache::forget($this->openedAtKey($service));
Cache::forget($this->trialLockKey($service));
}
protected function setState(string $service, string $state): void
{
Cache::put($this->stateKey($service), $state, 86400);
}
protected function isRecoverableError(Throwable $throwable): bool
{
$patterns = [
'SQLSTATE',
'Connection refused',
'Table .* doesn\'t exist',
'Base table or view not found',
'Connection timed out',
'Too many connections',
];
foreach ($patterns as $pattern) {
if (preg_match('/'.$pattern.'/i', $throwable->getMessage()) === 1) {
return true;
}
}
return false;
}
protected function failureThreshold(string $service): int
{
return (int) config(
sprintf('mcp.circuit_breaker.%s.threshold', $service),
config('mcp.circuit_breaker.default_threshold', 5),
);
}
protected function resetTimeout(string $service): int
{
return (int) config(
sprintf('mcp.circuit_breaker.%s.reset_timeout', $service),
config('mcp.circuit_breaker.default_reset_timeout', 60),
);
}
protected function failureWindow(string $service): int
{
return (int) config(
sprintf('mcp.circuit_breaker.%s.failure_window', $service),
config('mcp.circuit_breaker.default_failure_window', 120),
);
}
protected function atomicIncrement(string $key, int $ttl): int
{
$lock = Cache::lock($key.':lock', 5);
try {
$lock->block(3);
$value = (int) Cache::get($key, 0) + 1;
Cache::put($key, $value, $ttl);
return $value;
} finally {
rescue(static fn (): mixed => $lock->release(), report: false);
}
}
protected function atomicDecrement(string $key): int
{
$lock = Cache::lock($key.':lock', 5);
try {
$lock->block(3);
$value = max((int) Cache::get($key, 0) - 1, 0);
Cache::put($key, $value, self::COUNTER_TTL);
return $value;
} finally {
rescue(static fn (): mixed => $lock->release(), report: false);
}
}
protected function acquireTrialLock(string $service): bool
{
return Cache::add($this->trialLockKey($service), true, 30);
}
protected function releaseTrialLock(string $service): void
{
Cache::forget($this->trialLockKey($service));
}
protected function stateKey(string $service): string
{
return self::CACHE_PREFIX.$service.':state';
}
protected function failureCountKey(string $service): string
{
return self::CACHE_PREFIX.$service.':failures';
}
protected function successCountKey(string $service): string
{
return self::CACHE_PREFIX.$service.':successes';
}
protected function lastFailureKey(string $service): string
{
return self::CACHE_PREFIX.$service.':last_failure';
}
protected function openedAtKey(string $service): string
{
return self::CACHE_PREFIX.$service.':opened_at';
}
protected function trialLockKey(string $service): string
{
return self::CACHE_PREFIX.$service.':trial_lock';
}
}
}
namespace Core\Mcp\Exceptions {
use RuntimeException;
final class CircuitOpenException extends RuntimeException
{
public function __construct(
public readonly string $service,
string $message = '',
) {
parent::__construct($message !== '' ? $message : sprintf(
"Service '%s' is temporarily unavailable. Please try again later.",
$service,
));
}
}
}

View file

@ -0,0 +1,215 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
namespace Core\Mcp\Services;
final class DataRedactor
{
protected const REDACTED = '[REDACTED]';
protected const SENSITIVE_KEYS = [
'password',
'passwd',
'secret',
'token',
'api_key',
'apikey',
'api-key',
'auth',
'authorization',
'bearer',
'credential',
'credentials',
'private_key',
'privatekey',
'access_token',
'refresh_token',
'session_token',
'jwt',
'ssn',
'social_security',
'credit_card',
'creditcard',
'card_number',
'cvv',
'cvc',
'pin',
'routing_number',
'account_number',
'bank_account',
];
protected const PII_KEYS = [
'email',
'phone',
'telephone',
'mobile',
'address',
'street',
'postcode',
'zip',
'zipcode',
'date_of_birth',
'dob',
'birthdate',
'national_insurance',
'ni_number',
'passport',
'license',
'licence',
];
public function redact(mixed $data, int $maxDepth = 10): mixed
{
if ($maxDepth <= 0) {
return '[MAX_DEPTH_EXCEEDED]';
}
if (is_array($data)) {
return $this->redactArray($data, $maxDepth - 1);
}
if (is_string($data)) {
return $this->redactString($data);
}
return $data;
}
public function summarize(mixed $data, int $maxDepth = 3): mixed
{
if ($maxDepth <= 0) {
return '[...]';
}
if (is_array($data)) {
$result = [];
$count = count($data);
$limit = 10;
$items = array_slice($data, 0, $limit, true);
foreach ($items as $key => $value) {
$lowerKey = strtolower((string) $key);
if ($this->isSensitiveKey($lowerKey)) {
$result[$key] = self::REDACTED;
continue;
}
if ($this->isPiiKey($lowerKey) && is_string($value)) {
$result[$key] = $this->partialRedact($value);
continue;
}
$result[$key] = $this->summarize($value, $maxDepth - 1);
}
if ($count > $limit) {
$result['_truncated'] = sprintf('... and %d more items', $count - $limit);
}
return $result;
}
if (is_string($data)) {
$redacted = $this->redactString($data);
return strlen($redacted) > 100
? substr($redacted, 0, 97).'...'
: $redacted;
}
return $data;
}
protected function redactArray(array $data, int $maxDepth): array
{
$result = [];
foreach ($data as $key => $value) {
$lowerKey = strtolower((string) $key);
if ($this->isSensitiveKey($lowerKey)) {
$result[$key] = self::REDACTED;
continue;
}
if ($this->isPiiKey($lowerKey) && is_string($value)) {
$result[$key] = $this->partialRedact($value);
continue;
}
if (is_array($value)) {
$result[$key] = $maxDepth <= 0
? '[MAX_DEPTH_EXCEEDED]'
: $this->redactArray($value, $maxDepth - 1);
continue;
}
$result[$key] = is_string($value)
? $this->redactString($value)
: $value;
}
return $result;
}
protected function isSensitiveKey(string $key): bool
{
foreach (self::SENSITIVE_KEYS as $sensitiveKey) {
if (str_contains($key, $sensitiveKey)) {
return true;
}
}
return false;
}
protected function isPiiKey(string $key): bool
{
foreach (self::PII_KEYS as $piiKey) {
if (str_contains($key, $piiKey)) {
return true;
}
}
return false;
}
protected function redactString(string $value): string
{
$value = preg_replace('/Bearer\s+[A-Za-z0-9\-_\.]+/i', 'Bearer '.self::REDACTED, $value) ?? $value;
$value = preg_replace('/Basic\s+[A-Za-z0-9+\/=]+/i', 'Basic '.self::REDACTED, $value) ?? $value;
$value = preg_replace('/\b(sk|pk|key|api|token)_[A-Za-z0-9]{16,}\b/i', '$1_'.self::REDACTED, $value) ?? $value;
$value = preg_replace('/eyJ[A-Za-z0-9_-]*\.eyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]*/i', self::REDACTED, $value) ?? $value;
$value = preg_replace('/[A-Z]{2}\s?\d{2}\s?\d{2}\s?\d{2}\s?[A-Z]/i', self::REDACTED, $value) ?? $value;
$value = preg_replace('/\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/', self::REDACTED, $value) ?? $value;
return $value;
}
protected function partialRedact(string $value): string
{
$length = strlen($value);
if ($length <= 4) {
return self::REDACTED;
}
if ($length <= 8) {
return substr($value, 0, 2).'***'.substr($value, -1);
}
$visible = min(3, (int) floor($length / 4));
return substr($value, 0, $visible).'***'.substr($value, -$visible);
}
}

View file

@ -0,0 +1,271 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
namespace Core\Mcp\Services;
use Illuminate\Support\Facades\Cache;
use Symfony\Component\Yaml\Yaml;
final class McpHealthService
{
public const STATUS_ONLINE = 'online';
public const STATUS_OFFLINE = 'offline';
public const STATUS_DEGRADED = 'degraded';
public const STATUS_UNKNOWN = 'unknown';
protected int $cacheTtl = 60;
protected int $timeout = 5;
public function check(string $serverId, bool $forceRefresh = false): array
{
$cacheKey = sprintf('mcp:health:%s', $serverId);
if (! $forceRefresh && Cache::has($cacheKey)) {
return (array) Cache::get($cacheKey, []);
}
$server = $this->loadServerConfig($serverId);
if ($server === null) {
$result = $this->buildResult(self::STATUS_UNKNOWN, 'Server not found');
Cache::put($cacheKey, $result, $this->cacheTtl);
return $result;
}
$result = $this->pingServer($server);
Cache::put($cacheKey, $result, $this->cacheTtl);
return $result;
}
public function checkAll(bool $forceRefresh = false): array
{
$results = [];
foreach ($this->registeredServers() as $serverId) {
$results[$serverId] = $this->check($serverId, $forceRefresh);
}
return $results;
}
public function getCachedStatus(string $serverId): ?array
{
$status = Cache::get(sprintf('mcp:health:%s', $serverId));
return is_array($status) ? $status : null;
}
public function clearCache(string $serverId): void
{
Cache::forget(sprintf('mcp:health:%s', $serverId));
}
public function clearAllCache(): void
{
foreach ($this->registeredServers() as $serverId) {
$this->clearCache($serverId);
}
}
public function getStatusBadge(string $status): string
{
return match ($status) {
self::STATUS_ONLINE => '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">Online</span>',
self::STATUS_OFFLINE => '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">Offline</span>',
self::STATUS_DEGRADED => '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">Degraded</span>',
default => '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">Unknown</span>',
};
}
public function getStatusColour(string $status): string
{
return match ($status) {
self::STATUS_ONLINE => 'green',
self::STATUS_OFFLINE => 'red',
self::STATUS_DEGRADED => 'yellow',
default => 'gray',
};
}
protected function pingServer(array $server): array
{
$connection = (array) ($server['connection'] ?? []);
$type = (string) ($connection['type'] ?? 'stdio');
if ($type !== 'stdio') {
return $this->buildResult(self::STATUS_UNKNOWN, sprintf(
"Connection type '%s' health check not supported",
$type,
));
}
$command = trim((string) ($connection['command'] ?? ''));
if ($command === '') {
return $this->buildResult(self::STATUS_OFFLINE, 'No command configured');
}
$args = array_map(static fn (mixed $value): string => (string) $value, (array) ($connection['args'] ?? []));
$cwd = $this->resolveEnvVars((string) ($connection['cwd'] ?? getcwd()));
$payload = json_encode([
'jsonrpc' => '2.0',
'method' => 'initialize',
'params' => [
'protocolVersion' => '2024-11-05',
'capabilities' => new \stdClass,
'clientInfo' => [
'name' => 'mcp-health-check',
'version' => '1.0.0',
],
],
'id' => 1,
], JSON_UNESCAPED_SLASHES);
$result = $this->executeProcess(array_merge([$command], $args), $cwd, $payload.PHP_EOL);
$duration = (int) ($result['response_time_ms'] ?? 0);
$output = (string) ($result['output'] ?? '');
$error = trim((string) ($result['error'] ?? ''));
$exitCode = (int) ($result['exit_code'] ?? 1);
if ($exitCode === 0 && $output !== '') {
foreach (preg_split('/\R/', trim($output)) ?: [] as $line) {
$decoded = json_decode($line, true);
if (is_array($decoded) && isset($decoded['result'])) {
return $this->buildResult(self::STATUS_ONLINE, 'Server responding', [
'response_time_ms' => $duration,
'server_info' => $decoded['result']['serverInfo'] ?? null,
'protocol_version' => $decoded['result']['protocolVersion'] ?? null,
]);
}
}
return $this->buildResult(self::STATUS_DEGRADED, 'Server started but returned unexpected response', [
'response_time_ms' => $duration,
'output' => substr($output, 0, 500),
]);
}
return $this->buildResult(self::STATUS_OFFLINE, 'Server failed to start', [
'response_time_ms' => $duration,
'exit_code' => $exitCode,
'error' => $error !== '' ? substr($error, 0, 500) : null,
]);
}
protected function executeProcess(array $command, string $cwd, string $input): array
{
$descriptors = [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];
$startedAt = microtime(true);
$process = @proc_open($command, $descriptors, $pipes, $cwd);
if (! is_resource($process)) {
return [
'exit_code' => 1,
'output' => '',
'error' => 'Unable to start process',
'response_time_ms' => 0,
];
}
fwrite($pipes[0], $input);
fclose($pipes[0]);
stream_set_blocking($pipes[1], false);
stream_set_blocking($pipes[2], false);
$stdout = '';
$stderr = '';
$timedOut = false;
while (true) {
$stdout .= stream_get_contents($pipes[1]);
$stderr .= stream_get_contents($pipes[2]);
$status = proc_get_status($process);
if (! is_array($status) || ! ($status['running'] ?? false)) {
break;
}
if ((microtime(true) - $startedAt) >= $this->timeout) {
$timedOut = true;
proc_terminate($process, 9);
break;
}
usleep(100000);
}
$stdout .= stream_get_contents($pipes[1]);
$stderr .= stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
$exitCode = $timedOut ? 124 : proc_close($process);
return [
'exit_code' => $exitCode,
'output' => $stdout,
'error' => $timedOut ? trim($stderr."\nTimed out waiting for MCP response.") : $stderr,
'response_time_ms' => (int) round((microtime(true) - $startedAt) * 1000),
];
}
protected function buildResult(string $status, string $message, array $extra = []): array
{
return array_merge([
'status' => $status,
'message' => $message,
'checked_at' => now()->toIso8601String(),
], array_filter($extra, static fn (mixed $value): bool => $value !== null));
}
protected function registeredServers(): array
{
$servers = $this->loadRegistry()['servers'] ?? [];
return array_values(array_filter(array_map(
static fn (mixed $server): ?string => is_array($server) && isset($server['id']) ? (string) $server['id'] : null,
is_array($servers) ? $servers : [],
)));
}
protected function loadRegistry(): array
{
$path = resource_path('mcp/registry.yaml');
return file_exists($path) ? (array) Yaml::parseFile($path) : ['servers' => []];
}
protected function loadServerConfig(string $serverId): ?array
{
$path = resource_path(sprintf('mcp/servers/%s.yaml', $serverId));
return file_exists($path) ? (array) Yaml::parseFile($path) : null;
}
protected function resolveEnvVars(string $value): string
{
return preg_replace_callback('/\$\{([^}]+)\}/', static function (array $matches): string {
$parts = explode(':-', $matches[1], 2);
$name = $parts[0];
$default = $parts[1] ?? '';
return (string) env($name, $default);
}, $value) ?? $value;
}
}

View file

@ -0,0 +1,351 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
namespace Core\Mcp\Services;
use Carbon\CarbonImmutable;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
final class McpMetricsService
{
protected string $statsTable = 'mcp_tool_call_stats';
protected string $callsTable = 'mcp_tool_calls';
public function getOverview(int $days = 7): array
{
$currentStart = CarbonImmutable::now()->subDays($days - 1)->startOfDay();
$currentEnd = CarbonImmutable::now()->endOfDay();
$previousStart = $currentStart->subDays($days);
$previousEnd = $currentStart->subDay()->endOfDay();
$current = $this->statsInRange($currentStart, $currentEnd);
$previous = $this->statsInRange($previousStart, $previousEnd);
$totalCalls = (int) $current->sum('call_count');
$successCalls = (int) $current->sum('success_count');
$errorCalls = (int) $current->sum('error_count');
$previousCalls = (int) $previous->sum('call_count');
$totalDuration = (float) $current->sum('total_duration_ms');
return [
'total_calls' => $totalCalls,
'success_calls' => $successCalls,
'error_calls' => $errorCalls,
'success_rate' => $totalCalls > 0 ? round(($successCalls / $totalCalls) * 100, 1) : 0.0,
'avg_duration_ms' => $totalCalls > 0 ? round($totalDuration / $totalCalls, 1) : 0.0,
'calls_trend_percent' => $previousCalls > 0 ? round((($totalCalls - $previousCalls) / $previousCalls) * 100, 1) : 0.0,
'unique_tools' => $current->pluck('tool_name')->filter()->unique()->count(),
'unique_servers' => $current->pluck('server_id')->filter()->unique()->count(),
'period_days' => $days,
];
}
public function getDailyTrend(int $days = 7): Collection
{
$result = collect();
$start = CarbonImmutable::now()->subDays($days - 1)->startOfDay();
$end = CarbonImmutable::now()->endOfDay();
$rows = $this->dailyTrendRows($start, $end)->keyBy('date');
for ($offset = 0; $offset < $days; $offset++) {
$date = $start->addDays($offset)->toDateString();
$row = $rows->get($date);
$totalCalls = (int) ($row->total_calls ?? 0);
$totalSuccess = (int) ($row->total_success ?? 0);
$totalErrors = (int) ($row->total_errors ?? 0);
$result->push(collect([
'date' => $date,
'date_formatted' => Carbon::parse($date)->format('M j'),
'total_calls' => $totalCalls,
'total_success' => $totalSuccess,
'total_errors' => $totalErrors,
'success_rate' => $totalCalls > 0 ? round(($totalSuccess / $totalCalls) * 100, 1) : 0.0,
]));
}
return $result;
}
public function getTopTools(int $days = 7, int $limit = 10): Collection
{
if (! Schema::hasTable($this->statsTable)) {
return collect();
}
return DB::table($this->statsTable)
->select('tool_name')
->selectRaw('SUM(call_count) as call_count')
->selectRaw('SUM(success_count) as success_count')
->selectRaw('SUM(error_count) as error_count')
->selectRaw('SUM(total_duration_ms) as total_duration_ms')
->whereBetween('date', [
CarbonImmutable::now()->subDays($days - 1)->toDateString(),
CarbonImmutable::now()->toDateString(),
])
->groupBy('tool_name')
->orderByDesc('call_count')
->limit($limit)
->get()
->map(function (object $row): Collection {
$callCount = (int) $row->call_count;
$successCount = (int) $row->success_count;
return collect([
'tool_name' => (string) $row->tool_name,
'call_count' => $callCount,
'success_count' => $successCount,
'error_count' => (int) $row->error_count,
'success_rate' => $callCount > 0 ? round(($successCount / $callCount) * 100, 1) : 0.0,
'avg_duration_ms' => $callCount > 0 ? round(((int) $row->total_duration_ms) / $callCount, 1) : 0.0,
]);
});
}
public function getServerStats(int $days = 7): Collection
{
if (! Schema::hasTable($this->statsTable)) {
return collect();
}
return DB::table($this->statsTable)
->select('server_id')
->selectRaw('SUM(call_count) as call_count')
->selectRaw('SUM(success_count) as success_count')
->selectRaw('SUM(error_count) as error_count')
->selectRaw('COUNT(DISTINCT tool_name) as unique_tools')
->whereBetween('date', [
CarbonImmutable::now()->subDays($days - 1)->toDateString(),
CarbonImmutable::now()->toDateString(),
])
->groupBy('server_id')
->orderByDesc('call_count')
->get()
->map(function (object $row): Collection {
$callCount = (int) $row->call_count;
$successCount = (int) $row->success_count;
return collect([
'server_id' => (string) $row->server_id,
'call_count' => $callCount,
'success_count' => $successCount,
'error_count' => (int) $row->error_count,
'unique_tools' => (int) $row->unique_tools,
'success_rate' => $callCount > 0 ? round(($successCount / $callCount) * 100, 1) : 0.0,
]);
});
}
public function getRecentCalls(int $limit = 20): Collection
{
if (! Schema::hasTable($this->callsTable)) {
return collect();
}
return DB::table($this->callsTable)
->orderByDesc('created_at')
->limit($limit)
->get()
->map(function (object $row): Collection {
$createdAt = isset($row->created_at) ? Carbon::parse((string) $row->created_at) : null;
$durationMs = isset($row->duration_ms) ? (int) $row->duration_ms : null;
return collect([
'id' => $row->id,
'server_id' => (string) ($row->server_id ?? ''),
'tool_name' => (string) ($row->tool_name ?? ''),
'success' => (bool) ($row->success ?? false),
'duration' => $this->humanDuration($durationMs),
'duration_ms' => $durationMs,
'error_message' => $row->error_message,
'session_id' => $row->session_id,
'plan_slug' => $row->plan_slug,
'created_at' => $createdAt?->diffForHumans(),
'created_at_full' => $createdAt?->toIso8601String(),
]);
});
}
public function getErrorBreakdown(int $days = 7): Collection
{
if (! Schema::hasTable($this->callsTable)) {
return collect();
}
return DB::table($this->callsTable)
->select('tool_name', 'error_code')
->selectRaw('COUNT(*) as error_count')
->where('success', false)
->where('created_at', '>=', CarbonImmutable::now()->subDays($days)->startOfDay()->toDateTimeString())
->groupBy('tool_name', 'error_code')
->orderByDesc('error_count')
->get()
->map(fn (object $row): Collection => collect([
'tool_name' => (string) ($row->tool_name ?? ''),
'error_code' => $row->error_code,
'error_count' => (int) $row->error_count,
]));
}
public function getToolPerformance(int $days = 7, int $limit = 10): Collection
{
if (! Schema::hasTable($this->callsTable)) {
return collect();
}
$rows = DB::table($this->callsTable)
->select('tool_name', 'duration_ms')
->whereNotNull('duration_ms')
->where('success', true)
->where('created_at', '>=', CarbonImmutable::now()->subDays($days)->startOfDay()->toDateTimeString())
->get()
->groupBy('tool_name');
return $rows->map(function (Collection $items, string $toolName): Collection {
$durations = $items->pluck('duration_ms')->map(static fn (mixed $value): int => (int) $value)->sort()->values();
$count = $durations->count();
return collect([
'tool_name' => $toolName,
'call_count' => $count,
'min_ms' => $count > 0 ? (int) $durations->first() : 0,
'max_ms' => $count > 0 ? (int) $durations->last() : 0,
'avg_ms' => $count > 0 ? round($durations->avg(), 1) : 0.0,
'p50_ms' => $this->percentile($durations, 50),
'p95_ms' => $this->percentile($durations, 95),
'p99_ms' => $this->percentile($durations, 99),
]);
})->sortByDesc('call_count')->take($limit)->values();
}
public function getHourlyDistribution(): Collection
{
$distribution = collect();
$hours = collect();
if (Schema::hasTable($this->callsTable)) {
$hours = DB::table($this->callsTable)
->select('success', 'created_at')
->where('created_at', '>=', CarbonImmutable::now()->subHours(24)->toDateTimeString())
->get()
->groupBy(static function (object $row): string {
return Carbon::parse((string) $row->created_at)->format('H');
});
}
for ($hour = 0; $hour < 24; $hour++) {
$key = str_pad((string) $hour, 2, '0', STR_PAD_LEFT);
$rows = $hours->get($key, collect());
$distribution->push(collect([
'hour' => $key,
'hour_formatted' => Carbon::createFromTime($hour)->format('ga'),
'call_count' => $rows->count(),
'success_count' => $rows->filter(static fn (object $row): bool => (bool) ($row->success ?? false))->count(),
]));
}
return $distribution;
}
public function getPlanActivity(int $days = 7, int $limit = 10): Collection
{
if (! Schema::hasTable($this->callsTable)) {
return collect();
}
return DB::table($this->callsTable)
->select('plan_slug')
->selectRaw('COUNT(*) as call_count')
->selectRaw('COUNT(DISTINCT tool_name) as unique_tools')
->selectRaw('SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as success_count')
->whereNotNull('plan_slug')
->where('created_at', '>=', CarbonImmutable::now()->subDays($days)->startOfDay()->toDateTimeString())
->groupBy('plan_slug')
->orderByDesc('call_count')
->limit($limit)
->get()
->map(function (object $row): Collection {
$callCount = (int) $row->call_count;
$successCount = (int) $row->success_count;
return collect([
'plan_slug' => (string) $row->plan_slug,
'call_count' => $callCount,
'unique_tools' => (int) $row->unique_tools,
'success_count' => $successCount,
'success_rate' => $callCount > 0 ? round(($successCount / $callCount) * 100, 1) : 0.0,
]);
});
}
protected function statsInRange(CarbonImmutable $start, CarbonImmutable $end): Collection
{
if (! Schema::hasTable($this->statsTable)) {
return collect();
}
return DB::table($this->statsTable)
->whereBetween('date', [$start->toDateString(), $end->toDateString()])
->get()
->map(static fn (object $row): Collection => collect((array) $row));
}
protected function dailyTrendRows(CarbonImmutable $start, CarbonImmutable $end): Collection
{
if (! Schema::hasTable($this->statsTable)) {
return collect();
}
return DB::table($this->statsTable)
->select('date')
->selectRaw('SUM(call_count) as total_calls')
->selectRaw('SUM(success_count) as total_success')
->selectRaw('SUM(error_count) as total_errors')
->whereBetween('date', [$start->toDateString(), $end->toDateString()])
->groupBy('date')
->orderBy('date')
->get();
}
protected function percentile(Collection $sortedValues, int $percentile): float
{
$count = $sortedValues->count();
if ($count === 0) {
return 0.0;
}
$index = ($percentile / 100) * ($count - 1);
$lower = (int) floor($index);
$upper = (int) ceil($index);
if ($lower === $upper) {
return (float) $sortedValues[$lower];
}
$fraction = $index - $lower;
return round(((float) $sortedValues[$lower]) + (((float) $sortedValues[$upper] - (float) $sortedValues[$lower]) * $fraction), 1);
}
protected function humanDuration(?int $durationMs): string
{
if ($durationMs === null || $durationMs <= 0) {
return '-';
}
if ($durationMs < 1000) {
return $durationMs.'ms';
}
return round($durationMs / 1000, 2).'s';
}
}

View file

@ -0,0 +1,176 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
namespace Core\Mcp\Services;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use ReflectionMethod;
final class McpWebhookDispatcher
{
public function dispatchToolExecuted(
int $workspaceId,
string $serverId,
string $toolName,
array $arguments,
bool $success,
int $durationMs,
?string $errorMessage = null
): void {
$endpointClass = $this->endpointModelClass();
if ($endpointClass === null) {
return;
}
$eventType = 'mcp.tool.executed';
$payload = [
'event' => $eventType,
'timestamp' => now()->toIso8601String(),
'data' => [
'server_id' => $serverId,
'tool_name' => $toolName,
'arguments' => $arguments,
'success' => $success,
'duration_ms' => $durationMs,
'error' => $errorMessage,
],
];
$query = $endpointClass::query();
$model = $query->getModel();
if (method_exists($model, 'scopeForWorkspace')) {
$query->forWorkspace($workspaceId);
} else {
$query->where('workspace_id', $workspaceId);
}
if (method_exists($model, 'scopeActive')) {
$query->active();
} else {
$query->where('active', true);
}
if (method_exists($model, 'scopeForEvent')) {
$query->forEvent($eventType);
} else {
$query->where(function ($inner) use ($eventType): void {
$inner->whereJsonContains('events', $eventType)
->orWhereJsonContains('events', '*');
});
}
foreach ($query->get() as $endpoint) {
$this->deliverWebhook($endpoint, $payload);
}
}
protected function deliverWebhook(object $endpoint, array $payload): void
{
$timestamp = (string) ($payload['timestamp'] ?? now()->toIso8601String());
$payloadJson = json_encode($payload, JSON_UNESCAPED_SLASHES);
$signature = $this->generateSignature($endpoint, $payloadJson, $timestamp);
try {
$response = $this->sendWebhook($endpoint, $payloadJson, [
'Content-Type' => 'application/json',
'X-Webhook-Signature' => $signature,
'X-Webhook-Event' => (string) $payload['event'],
'X-Webhook-Timestamp' => $timestamp,
]);
$this->recordDelivery($endpoint, $payload, $response->status(), $response->body(), $response->successful());
if ($response->successful() && method_exists($endpoint, 'recordSuccess')) {
$endpoint->recordSuccess();
}
if (! $response->successful() && method_exists($endpoint, 'recordFailure')) {
$endpoint->recordFailure();
}
} catch (\Throwable $throwable) {
$this->recordDelivery($endpoint, $payload, 0, $throwable->getMessage(), false);
if (method_exists($endpoint, 'recordFailure')) {
$endpoint->recordFailure();
}
}
}
protected function sendWebhook(object $endpoint, string $payloadJson, array $headers): Response
{
return Http::timeout(10)
->withHeaders($headers)
->withBody($payloadJson, 'application/json')
->post((string) $endpoint->url);
}
protected function recordDelivery(object $endpoint, array $payload, int $responseCode, string $responseBody, bool $successful): void
{
$deliveryClass = $this->deliveryModelClass();
if ($deliveryClass === null) {
return;
}
$deliveryClass::create([
'webhook_endpoint_id' => $endpoint->id,
'event_id' => 'evt_'.Str::random(24),
'event_type' => (string) $payload['event'],
'payload' => $payload,
'response_code' => $responseCode,
'response_body' => mb_substr($responseBody, 0, 1000),
'status' => $successful ? 'success' : 'failed',
'attempt' => 1,
'delivered_at' => $successful ? now() : null,
]);
}
protected function generateSignature(object $endpoint, string $payloadJson, string $timestamp): string
{
if (! method_exists($endpoint, 'generateSignature')) {
return '';
}
$method = new ReflectionMethod($endpoint, 'generateSignature');
return match (true) {
$method->getNumberOfRequiredParameters() <= 1 => (string) $endpoint->generateSignature($payloadJson),
default => (string) $endpoint->generateSignature($payloadJson, strtotime($timestamp) ?: time()),
};
}
protected function endpointModelClass(): ?string
{
foreach ([
'Core\\Api\\Models\\WebhookEndpoint',
'Core\\Mod\\Api\\Models\\WebhookEndpoint',
] as $class) {
if (class_exists($class)) {
return $class;
}
}
return null;
}
protected function deliveryModelClass(): ?string
{
foreach ([
'Core\\Api\\Models\\WebhookDelivery',
'Core\\Mod\\Api\\Models\\WebhookDelivery',
] as $class) {
if (class_exists($class)) {
return $class;
}
}
return null;
}
}

View file

@ -0,0 +1,343 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
namespace Core\Mcp\Services;
use Symfony\Component\Yaml\Yaml;
final class OpenApiGenerator
{
protected array $registry = ['servers' => []];
protected array $servers = [];
public function generate(): array
{
$this->loadRegistry();
$this->loadServers();
return [
'openapi' => '3.0.3',
'info' => $this->buildInfo(),
'servers' => $this->buildServers(),
'tags' => $this->buildTags(),
'paths' => $this->buildPaths(),
'components' => $this->buildComponents(),
];
}
public function toJson(): string
{
return (string) json_encode($this->generate(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
}
public function toYaml(): string
{
return Yaml::dump($this->generate(), 10, 2);
}
protected function loadRegistry(): void
{
$path = resource_path('mcp/registry.yaml');
$this->registry = file_exists($path) ? (array) Yaml::parseFile($path) : ['servers' => []];
}
protected function loadServers(): void
{
$this->servers = [];
foreach ((array) ($this->registry['servers'] ?? []) as $reference) {
if (! is_array($reference) || ! isset($reference['id'])) {
continue;
}
$id = (string) $reference['id'];
$path = resource_path(sprintf('mcp/servers/%s.yaml', $id));
$this->servers[$id] = file_exists($path)
? (array) Yaml::parseFile($path)
: ['id' => $id, 'name' => $id];
}
}
protected function buildInfo(): array
{
return [
'title' => 'Host UK MCP API',
'description' => 'HTTP API for MCP server discovery, tool execution, and resource reads.',
'version' => '1.0.0',
'contact' => [
'name' => 'Host UK Support',
'url' => 'https://host.uk.com/contact',
'email' => 'support@host.uk.com',
],
'license' => [
'name' => 'Proprietary',
],
];
}
protected function buildServers(): array
{
return [
[
'url' => 'https://mcp.host.uk.com/api/v1/mcp',
'description' => 'Production',
],
[
'url' => 'https://mcp.test/api/v1/mcp',
'description' => 'Local development',
],
];
}
protected function buildTags(): array
{
$tags = [
['name' => 'Discovery', 'description' => 'Server and tool discovery endpoints'],
['name' => 'Execution', 'description' => 'Tool execution and resource endpoints'],
];
foreach ($this->servers as $server) {
$tags[] = [
'name' => (string) ($server['name'] ?? $server['id'] ?? 'unknown'),
'description' => (string) ($server['tagline'] ?? $server['description'] ?? ''),
];
}
return $tags;
}
protected function buildPaths(): array
{
return [
'/servers' => [
'get' => [
'tags' => ['Discovery'],
'summary' => 'List all MCP servers',
'operationId' => 'listServers',
'security' => [['bearerAuth' => []], ['apiKeyAuth' => []]],
'responses' => [
'200' => [
'description' => 'List of available servers',
'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/ServerList']]],
],
],
],
],
'/servers/{serverId}' => [
'get' => [
'tags' => ['Discovery'],
'summary' => 'Get server details',
'operationId' => 'getServer',
'security' => [['bearerAuth' => []], ['apiKeyAuth' => []]],
'parameters' => [[
'name' => 'serverId',
'in' => 'path',
'required' => true,
'schema' => ['type' => 'string'],
]],
'responses' => [
'200' => [
'description' => 'Server details',
'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Server']]],
],
'404' => ['description' => 'Server not found'],
],
],
],
'/servers/{serverId}/tools' => [
'get' => [
'tags' => ['Discovery'],
'summary' => 'List server tools',
'operationId' => 'listServerTools',
'security' => [['bearerAuth' => []], ['apiKeyAuth' => []]],
'parameters' => [[
'name' => 'serverId',
'in' => 'path',
'required' => true,
'schema' => ['type' => 'string'],
]],
'responses' => [
'200' => [
'description' => 'Tool list',
'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/ToolList']]],
],
],
],
],
'/servers/{serverId}/resources' => [
'get' => [
'tags' => ['Discovery'],
'summary' => 'List server resources',
'operationId' => 'listServerResources',
'security' => [['bearerAuth' => []], ['apiKeyAuth' => []]],
'parameters' => [[
'name' => 'serverId',
'in' => 'path',
'required' => true,
'schema' => ['type' => 'string'],
]],
'responses' => [
'200' => [
'description' => 'Resource list',
'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/ResourceList']]],
],
],
],
],
'/tools/call' => [
'post' => [
'tags' => ['Execution'],
'summary' => 'Execute an MCP tool',
'operationId' => 'callTool',
'security' => [['bearerAuth' => []], ['apiKeyAuth' => []]],
'requestBody' => [
'required' => true,
'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/ToolCallRequest']]],
],
'responses' => [
'200' => [
'description' => 'Tool executed successfully',
'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/ToolCallResponse']]],
],
'400' => ['description' => 'Invalid request'],
'401' => ['description' => 'Unauthorized'],
'404' => ['description' => 'Server or tool not found'],
'500' => ['description' => 'Tool execution error'],
],
],
],
'/resources/{uri}' => [
'get' => [
'tags' => ['Execution'],
'summary' => 'Read a resource',
'operationId' => 'readResource',
'security' => [['bearerAuth' => []], ['apiKeyAuth' => []]],
'parameters' => [[
'name' => 'uri',
'in' => 'path',
'required' => true,
'schema' => ['type' => 'string'],
]],
'responses' => [
'200' => [
'description' => 'Resource payload',
'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/ResourceResponse']]],
],
],
],
],
];
}
protected function buildComponents(): array
{
return [
'securitySchemes' => [
'bearerAuth' => [
'type' => 'http',
'scheme' => 'bearer',
'description' => 'API key in bearer format, e.g. hk_xxx_yyy',
],
'apiKeyAuth' => [
'type' => 'apiKey',
'in' => 'header',
'name' => 'X-API-Key',
],
],
'schemas' => [
'ServerList' => [
'type' => 'object',
'properties' => [
'servers' => ['type' => 'array', 'items' => ['$ref' => '#/components/schemas/ServerSummary']],
'count' => ['type' => 'integer'],
],
],
'ServerSummary' => [
'type' => 'object',
'properties' => [
'id' => ['type' => 'string'],
'name' => ['type' => 'string'],
'tagline' => ['type' => 'string'],
'tool_count' => ['type' => 'integer'],
'resource_count' => ['type' => 'integer'],
],
],
'Server' => [
'type' => 'object',
'properties' => [
'id' => ['type' => 'string'],
'name' => ['type' => 'string'],
'tagline' => ['type' => 'string'],
'description' => ['type' => 'string'],
'tools' => ['type' => 'array', 'items' => ['$ref' => '#/components/schemas/Tool']],
'resources' => ['type' => 'array', 'items' => ['$ref' => '#/components/schemas/Resource']],
],
],
'Tool' => [
'type' => 'object',
'properties' => [
'name' => ['type' => 'string'],
'description' => ['type' => 'string'],
'inputSchema' => ['type' => 'object', 'additionalProperties' => true],
],
],
'Resource' => [
'type' => 'object',
'properties' => [
'uri' => ['type' => 'string'],
'name' => ['type' => 'string'],
'description' => ['type' => 'string'],
'mimeType' => ['type' => 'string'],
],
],
'ToolList' => [
'type' => 'object',
'properties' => [
'server' => ['type' => 'string'],
'tools' => ['type' => 'array', 'items' => ['$ref' => '#/components/schemas/Tool']],
'count' => ['type' => 'integer'],
],
],
'ToolCallRequest' => [
'type' => 'object',
'required' => ['server', 'tool'],
'properties' => [
'server' => ['type' => 'string'],
'tool' => ['type' => 'string'],
'arguments' => ['type' => 'object', 'additionalProperties' => true],
],
],
'ToolCallResponse' => [
'type' => 'object',
'properties' => [
'success' => ['type' => 'boolean'],
'server' => ['type' => 'string'],
'tool' => ['type' => 'string'],
'result' => ['type' => 'object', 'additionalProperties' => true],
'duration_ms' => ['type' => 'integer'],
'error' => ['type' => 'string'],
],
],
'ResourceResponse' => [
'type' => 'object',
'properties' => [
'uri' => ['type' => 'string'],
'content' => ['type' => 'object', 'additionalProperties' => true],
],
],
'ResourceList' => [
'type' => 'object',
'properties' => [
'server' => ['type' => 'string'],
'resources' => ['type' => 'array', 'items' => ['$ref' => '#/components/schemas/Resource']],
'count' => ['type' => 'integer'],
],
],
],
];
}
}

View file

@ -0,0 +1,117 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
namespace Core\Mcp\Services;
use Illuminate\Support\Facades\Cache;
final class ToolRateLimiter
{
protected const CACHE_PREFIX = 'mcp_rate_limit:';
public function check(string $identifier, string $toolName): array
{
if (! config('mcp.rate_limiting.enabled', true)) {
return ['limited' => false, 'remaining' => PHP_INT_MAX, 'retry_after' => null];
}
$limit = $this->limitForTool($toolName);
$cacheKey = $this->cacheKey($identifier, $toolName);
$current = (int) Cache::get($cacheKey, 0);
$decaySeconds = (int) config('mcp.rate_limiting.decay_seconds', 60);
if ($current >= $limit) {
$ttl = $this->ttl($cacheKey, $decaySeconds);
return [
'limited' => true,
'remaining' => 0,
'retry_after' => $ttl > 0 ? $ttl : $decaySeconds,
];
}
return [
'limited' => false,
'remaining' => max($limit - $current - 1, 0),
'retry_after' => null,
];
}
public function hit(string $identifier, string $toolName): void
{
if (! config('mcp.rate_limiting.enabled', true)) {
return;
}
$cacheKey = $this->cacheKey($identifier, $toolName);
$current = (int) Cache::get($cacheKey, 0);
$decaySeconds = (int) config('mcp.rate_limiting.decay_seconds', 60);
if ($current === 0) {
Cache::put($cacheKey, 1, $decaySeconds);
return;
}
Cache::increment($cacheKey);
}
public function clear(string $identifier, ?string $toolName = null): void
{
if ($toolName !== null) {
Cache::forget($this->cacheKey($identifier, $toolName));
return;
}
foreach (array_keys((array) config('mcp.rate_limiting.per_tool', [])) as $configuredTool) {
Cache::forget($this->cacheKey($identifier, (string) $configuredTool));
}
Cache::forget($this->cacheKey($identifier, '*'));
}
public function getStatus(string $identifier, string $toolName): array
{
$limit = $this->limitForTool($toolName);
$cacheKey = $this->cacheKey($identifier, $toolName);
$current = (int) Cache::get($cacheKey, 0);
$ttl = $this->ttl($cacheKey, (int) config('mcp.rate_limiting.decay_seconds', 60));
return [
'limit' => $limit,
'remaining' => max($limit - $current, 0),
'reset_at' => $ttl > 0 ? now()->addSeconds($ttl)->toIso8601String() : null,
];
}
protected function limitForTool(string $toolName): int
{
$perTool = (array) config('mcp.rate_limiting.per_tool', []);
if (array_key_exists($toolName, $perTool)) {
return (int) $perTool[$toolName];
}
return (int) config('mcp.rate_limiting.calls_per_minute', 60);
}
protected function cacheKey(string $identifier, string $toolName): string
{
return self::CACHE_PREFIX.$identifier.':'.$toolName;
}
protected function ttl(string $cacheKey, int $default): int
{
try {
$ttl = Cache::ttl($cacheKey);
return is_int($ttl) ? $ttl : $default;
} catch (\Throwable) {
return $default;
}
}
}

View file

@ -0,0 +1,16 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
namespace Core\Front\Mcp\Contracts;
use Core\Front\Mcp\McpContext;
interface McpToolHandler
{
public static function schema(): array;
public function handle(array $args, McpContext $context): array;
}

View file

@ -0,0 +1,73 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
namespace Core\Front\Mcp;
use Closure;
final class McpContext
{
public function __construct(
private ?string $sessionId = null,
private ?object $currentPlan = null,
private ?Closure $notificationCallback = null,
private ?Closure $logCallback = null,
) {}
public function getSessionId(): ?string
{
return $this->sessionId;
}
public function setSessionId(?string $sessionId): void
{
$this->sessionId = $sessionId;
}
public function getCurrentPlan(): ?object
{
return $this->currentPlan;
}
public function setCurrentPlan(?object $plan): void
{
$this->currentPlan = $plan;
}
public function sendNotification(string $method, array $params = []): void
{
if ($this->notificationCallback instanceof Closure) {
($this->notificationCallback)($method, $params);
}
}
public function logToSession(string $message, string $type = 'info', array $data = []): void
{
if ($this->logCallback instanceof Closure) {
($this->logCallback)($message, $type, $data);
}
}
public function setNotificationCallback(?Closure $callback): void
{
$this->notificationCallback = $callback;
}
public function setLogCallback(?Closure $callback): void
{
$this->logCallback = $callback;
}
public function hasSession(): bool
{
return $this->sessionId !== null;
}
public function hasPlan(): bool
{
return $this->currentPlan !== null;
}
}

View file

@ -0,0 +1,249 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
namespace Mod\Mcp\Services;
use Core\Mod\Agentic\Models\AgentPlan;
use Core\Mod\Agentic\Models\AgentSession;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
final class AgentSessionService
{
protected const CACHE_PREFIX = 'mcp_session:';
public function start(
string $agentType,
?AgentPlan $plan = null,
?int $workspaceId = null,
array $initialContext = []
): AgentSession {
$session = AgentSession::start($plan, $agentType);
if ($workspaceId !== null) {
$session->update(['workspace_id' => $workspaceId]);
}
if ($initialContext !== []) {
$session->updateContextSummary($initialContext);
}
$this->cacheActiveSession($session);
return $session;
}
public function get(string $sessionId): ?AgentSession
{
return AgentSession::query()->where('session_id', $sessionId)->first();
}
public function resume(string $sessionId): ?AgentSession
{
$session = $this->get($sessionId);
if (! $session instanceof AgentSession) {
return null;
}
if ($session->status === AgentSession::STATUS_PAUSED) {
$session->resume();
}
$session->touchActivity();
$this->cacheActiveSession($session);
return $session;
}
public function getActiveSessions(?int $workspaceId = null): Collection
{
$query = AgentSession::query()->active();
if ($workspaceId !== null) {
$query->where('workspace_id', $workspaceId);
}
return $query->orderByDesc('last_active_at')->get();
}
public function getSessionsForPlan(AgentPlan $plan): Collection
{
return AgentSession::query()
->forPlan($plan)
->orderByDesc('created_at')
->get();
}
public function getLatestSessionForPlan(AgentPlan $plan): ?AgentSession
{
return AgentSession::query()
->forPlan($plan)
->orderByDesc('created_at')
->first();
}
public function end(string $sessionId, string $status = AgentSession::STATUS_COMPLETED, ?string $summary = null): ?AgentSession
{
$session = $this->get($sessionId);
if (! $session instanceof AgentSession) {
return null;
}
$session->end($status, $summary);
$this->clearCachedSession($session);
return $session;
}
public function pause(string $sessionId): ?AgentSession
{
$session = $this->get($sessionId);
if (! $session instanceof AgentSession) {
return null;
}
$session->pause();
return $session;
}
public function prepareHandoff(string $sessionId, string $summary, array $nextSteps = [], array $blockers = [], array $contextForNext = []): ?AgentSession
{
$session = $this->get($sessionId);
if (! $session instanceof AgentSession) {
return null;
}
$session->prepareHandoff($summary, $nextSteps, $blockers, $contextForNext);
return $session;
}
public function getHandoffContext(string $sessionId): ?array
{
return $this->get($sessionId)?->getHandoffContext();
}
public function continueFrom(string $previousSessionId, string $newAgentType): ?AgentSession
{
$previousSession = $this->get($previousSessionId);
if (! $previousSession instanceof AgentSession) {
return null;
}
$handoffContext = $previousSession->getHandoffContext();
$handoffNotes = (array) ($handoffContext['handoff_notes'] ?? []);
$contextForNext = (array) ($handoffNotes['context_for_next'] ?? []);
$newSession = $this->start(
$newAgentType,
$previousSession->plan,
$previousSession->workspace_id,
[
'continued_from' => $previousSession->session_id,
'previous_agent' => $previousSession->agent_type,
'handoff_notes' => $handoffNotes,
'inherited_context' => $contextForNext !== [] ? $contextForNext : ($handoffContext['context_summary'] ?? null),
],
);
$previousSession->end(AgentSession::STATUS_HANDED_OFF, 'Handed off to '.$newAgentType, $previousSession->handoff_notes);
$this->clearCachedSession($previousSession);
return $newSession;
}
public function setState(string $sessionId, string $key, mixed $value, ?int $ttl = null): void
{
Cache::put(
self::CACHE_PREFIX.$sessionId.':'.$key,
$value,
$ttl ?? $this->cacheTtl(),
);
}
public function getState(string $sessionId, string $key, mixed $default = null): mixed
{
return Cache::get(self::CACHE_PREFIX.$sessionId.':'.$key, $default);
}
public function exists(string $sessionId): bool
{
return AgentSession::query()->where('session_id', $sessionId)->exists();
}
public function isActive(string $sessionId): bool
{
return $this->get($sessionId)?->isActive() ?? false;
}
public function getSessionStats(?int $workspaceId = null, int $days = 7): array
{
$query = AgentSession::query()->where('created_at', '>=', now()->subDays($days));
if ($workspaceId !== null) {
$query->where('workspace_id', $workspaceId);
}
$sessions = $query->get();
$byStatus = $sessions->groupBy('status')->map->count()->toArray();
$byAgentType = $sessions->groupBy('agent_type')->map->count()->toArray();
$avgDuration = round((float) $sessions
->where('status', AgentSession::STATUS_COMPLETED)
->avg(static fn (AgentSession $session): int => $session->getDuration() ?? 0), 1);
return [
'total' => $sessions->count(),
'active' => $sessions->where('status', AgentSession::STATUS_ACTIVE)->count(),
'by_status' => $byStatus,
'by_agent_type' => $byAgentType,
'avg_duration_minutes' => $avgDuration,
'period_days' => $days,
];
}
public function cleanupStaleSessions(int $hoursInactive = 24): int
{
$cutoff = now()->subHours($hoursInactive);
$sessions = AgentSession::query()
->active()
->where('last_active_at', '<', $cutoff)
->get();
foreach ($sessions as $session) {
$session->fail('Session timed out due to inactivity');
$this->clearCachedSession($session);
}
return $sessions->count();
}
protected function cacheActiveSession(AgentSession $session): void
{
Cache::put(self::CACHE_PREFIX.'active:'.$session->session_id, [
'session_id' => $session->session_id,
'agent_type' => $session->agent_type,
'plan_id' => $session->agent_plan_id,
'workspace_id' => $session->workspace_id,
'started_at' => $session->started_at?->toIso8601String(),
], $this->cacheTtl());
}
protected function clearCachedSession(AgentSession $session): void
{
Cache::forget(self::CACHE_PREFIX.'active:'.$session->session_id);
}
protected function cacheTtl(): int
{
return (int) config('mcp.session.cache_ttl', 86400);
}
}

View file

@ -0,0 +1,3 @@
servers:
- id: host-hub
- id: marketing

View file

@ -0,0 +1,44 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
require_once dirname(__DIR__).'/Support/bootstrap.php';
mcpDefineLaravelMcpStubs();
mcpRequire('Mcp/Resources/AppConfig.php');
use Core\Mcp\Resources\AppConfig;
use Laravel\Mcp\Request;
test('AppConfig_handle_Good_returns_application_config_as_pretty_printed_json', function (): void {
config()->set('app.name', 'Host Hub');
config()->set('app.env', 'testing');
config()->set('app.debug', true);
config()->set('app.url', 'https://host.test');
$response = (new AppConfig)->handle(new Request);
$payload = json_decode($response->content, true, 512, JSON_THROW_ON_ERROR);
expect($payload['name'])->toBe('Host Hub')
->and($payload['url'])->toBe('https://host.test');
});
test('AppConfig_handle_Bad_keeps_missing_values_as_nulls_instead_of_throwing', function (): void {
config()->set('app.name', null);
config()->set('app.url', null);
$payload = json_decode((new AppConfig)->handle(new Request)->content, true, 512, JSON_THROW_ON_ERROR);
expect($payload['name'])->toBeNull()
->and($payload['url'])->toBeNull();
});
test('AppConfig_handle_Ugly_preserves_json_encoding_for_slash_containing_urls', function (): void {
config()->set('app.url', 'https://host.test/api/v1');
$content = (new AppConfig)->handle(new Request)->content;
expect($content)->toContain('https://host.test/api/v1');
});

View file

@ -0,0 +1,83 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
require_once dirname(__DIR__).'/Support/bootstrap.php';
mcpDefineLaravelMcpStubs();
mcpRequire('Mcp/Resources/ContentResource.php');
use Core\Mcp\Resources\ContentResource;
use Illuminate\Support\Collection;
use Laravel\Mcp\Request;
test('ContentResource_handle_Good_renders_frontmatter_excerpt_and_markdown_body', function (): void {
$workspace = (object) ['id' => 1, 'slug' => 'host'];
$item = (object) [
'title' => 'Launch Post',
'slug' => 'launch-post',
'type' => 'post',
'status' => 'publish',
'author' => (object) ['name' => 'Virgil'],
'categories' => [(object) ['name' => 'News']],
'tags' => [(object) ['name' => 'Launch']],
'excerpt' => 'Big launch.',
'content_markdown' => '# Hello',
'created_at' => now(),
'updated_at' => now(),
];
$resource = new class($workspace, $item) extends ContentResource
{
public function __construct(private object $workspaceFixture, private object $itemFixture)
{
}
protected function resolveWorkspace(string $identifier): ?object
{
return $this->workspaceFixture;
}
protected function resolveContentItem(object $workspace, string $identifier): ?object
{
return $this->itemFixture;
}
};
$content = $resource->handle(new Request(['uri' => 'content://host/launch-post']))->content;
expect($content)->toContain("title: Launch Post")
->and($content)->toContain('> Big launch.')
->and($content)->toContain('# Hello');
});
test('ContentResource_handle_Bad_rejects_invalid_content_uris', function (): void {
$resource = new class extends ContentResource {};
expect($resource->handle(new Request(['uri' => 'invalid://x']))->content)
->toBe('Invalid URI format. Expected: content://{workspace}/{slug}');
});
test('ContentResource_list_Ugly_can_be_overridden_to_surface_resource_lists_without_the_database', function (): void {
$resource = new class extends ContentResource
{
protected function listResources(): array
{
return [[
'uri' => 'content://host/launch-post',
'name' => 'Launch Post',
'description' => 'Post: Launch Post',
'mimeType' => 'text/markdown',
]];
}
};
expect(mcpInvokeProtected($resource, 'listResources'))->toBe([[
'uri' => 'content://host/launch-post',
'name' => 'Launch Post',
'description' => 'Post: Launch Post',
'mimeType' => 'text/markdown',
]]);
});

View file

@ -0,0 +1,63 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
require_once dirname(__DIR__).'/Support/bootstrap.php';
mcpDefineLaravelMcpStubs();
mcpRequire('Mcp/Resources/DatabaseSchema.php');
use Core\Mcp\Resources\DatabaseSchema;
use Laravel\Mcp\Request;
test('DatabaseSchema_handle_Good_serialises_each_table_description_as_pretty_json', function (): void {
$resource = new class extends DatabaseSchema
{
protected function tables(): array
{
return ['users'];
}
protected function describeTable(string $tableName): array
{
return [['Field' => 'id', 'Type' => 'bigint']];
}
};
$payload = json_decode($resource->handle(new Request)->content, true, 512, JSON_THROW_ON_ERROR);
expect($payload['users'][0]['Field'])->toBe('id');
});
test('DatabaseSchema_handle_Bad_returns_an_empty_json_object_when_no_tables_are_available', function (): void {
$resource = new class extends DatabaseSchema
{
protected function tables(): array
{
return [];
}
};
expect($resource->handle(new Request)->content)->toBe('[]');
});
test('DatabaseSchema_handle_Ugly_aggregates_multiple_tables_in_a_single_response', function (): void {
$resource = new class extends DatabaseSchema
{
protected function tables(): array
{
return ['users', 'posts'];
}
protected function describeTable(string $tableName): array
{
return [['table' => $tableName]];
}
};
$payload = json_decode($resource->handle(new Request)->content, true, 512, JSON_THROW_ON_ERROR);
expect(array_keys($payload))->toBe(['users', 'posts']);
});

View file

@ -0,0 +1,52 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
require_once dirname(__DIR__).'/Support/bootstrap.php';
mcpRequire('Mod/Mcp/Services/AgentSessionService.php');
use Core\Mod\Agentic\Models\AgentPlan;
use Core\Mod\Agentic\Models\AgentSession;
use Illuminate\Support\Facades\Cache;
use Mod\Mcp\Services\AgentSessionService;
beforeEach(function (): void {
Cache::flush();
});
test('AgentSessionService_start_Good_creates_and_caches_a_new_active_session', function (): void {
$workspace = createWorkspace();
$plan = AgentPlan::factory()->create(['workspace_id' => $workspace->id]);
$service = new AgentSessionService;
$session = $service->start('opus', $plan, $workspace->id, ['task' => 'draft']);
expect($session)->toBeInstanceOf(AgentSession::class)
->and($service->exists($session->session_id))->toBeTrue()
->and(Cache::get('mcp_session:active:'.$session->session_id)['workspace_id'])->toBe($workspace->id);
});
test('AgentSessionService_end_Bad_returns_null_for_unknown_sessions', function (): void {
$service = new AgentSessionService;
expect($service->end('missing-session'))->toBeNull()
->and($service->getHandoffContext('missing-session'))->toBeNull();
});
test('AgentSessionService_continueFrom_Ugly_hands_off_the_previous_session_and_starts_a_follow_up', function (): void {
$workspace = createWorkspace();
$plan = AgentPlan::factory()->create(['workspace_id' => $workspace->id]);
$service = new AgentSessionService;
$first = $service->start('opus', $plan, $workspace->id, ['task' => 'phase-one']);
$service->prepareHandoff($first->session_id, 'Done', ['next'], ['none'], ['checkpoint' => 'alpha']);
$second = $service->continueFrom($first->session_id, 'sonnet');
expect($second)->toBeInstanceOf(AgentSession::class)
->and($first->fresh()->status)->toBe(AgentSession::STATUS_HANDED_OFF)
->and($second->context_summary['continued_from'])->toBe($first->session_id)
->and($second->context_summary['previous_agent'])->toBe('opus');
});

View file

@ -0,0 +1,69 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
require_once dirname(__DIR__).'/Support/bootstrap.php';
mcpRequire('Mcp/Services/CircuitBreaker.php');
use Core\Mcp\Exceptions\CircuitOpenException;
use Core\Mcp\Services\CircuitBreaker;
use Illuminate\Support\Facades\Cache;
beforeEach(function (): void {
Cache::flush();
config()->set('mcp.circuit_breaker.default_threshold', 2);
config()->set('mcp.circuit_breaker.default_reset_timeout', 60);
config()->set('mcp.circuit_breaker.default_failure_window', 120);
});
test('CircuitBreaker_call_Good_allows_closed_operations_and_records_success', function (): void {
$breaker = new CircuitBreaker;
$result = $breaker->call('agentic', static fn (): string => 'ok');
expect($result)->toBe('ok')
->and($breaker->getState('agentic'))->toBe(CircuitBreaker::STATE_CLOSED)
->and($breaker->getStats('agentic')['successes'])->toBe(1);
});
test('CircuitBreaker_call_Bad_trips_open_and_fails_fast_without_a_fallback', function (): void {
$breaker = new CircuitBreaker;
try {
$breaker->call('agentic', static function (): never {
throw new RuntimeException('Connection refused');
});
} catch (RuntimeException) {
}
try {
$breaker->call('agentic', static function (): never {
throw new RuntimeException('Connection refused');
});
} catch (RuntimeException) {
}
expect($breaker->getState('agentic'))->toBe(CircuitBreaker::STATE_OPEN);
$breaker->call('agentic', static fn (): string => 'ignored');
})->throws(CircuitOpenException::class);
test('CircuitBreaker_call_Ugly_uses_fallback_when_half_open_trial_is_already_locked', function (): void {
$breaker = new CircuitBreaker;
Cache::put('circuit_breaker:content:state', CircuitBreaker::STATE_OPEN, 86400);
Cache::put('circuit_breaker:content:opened_at', time() - 120, 86400);
Cache::put('circuit_breaker:content:trial_lock', true, 30);
$result = $breaker->call(
'content',
static fn (): string => 'never',
static fn (): string => 'fallback',
);
expect($result)->toBe('fallback')
->and($breaker->getState('content'))->toBe(CircuitBreaker::STATE_HALF_OPEN);
});

View file

@ -0,0 +1,51 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
require_once dirname(__DIR__).'/Support/bootstrap.php';
mcpRequire('Mcp/Services/DataRedactor.php');
use Core\Mcp\Services\DataRedactor;
test('DataRedactor_redact_Good_fully_redacts_sensitive_keys_and_string_patterns', function (): void {
$service = new DataRedactor;
$redacted = $service->redact([
'password' => 'super-secret',
'header' => 'Bearer sk_1234567890abcdefghijklmn',
'card' => '4111-1111-1111-1111',
]);
expect($redacted['password'])->toBe('[REDACTED]')
->and($redacted['header'])->toContain('Bearer [REDACTED]')
->and($redacted['card'])->toBe('[REDACTED]');
});
test('DataRedactor_summarize_Bad_partially_redacts_pii_and_truncates_large_arrays', function (): void {
$service = new DataRedactor;
$summary = $service->summarize([
'email' => 'somebody@example.com',
'items' => range(1, 12),
]);
expect($summary['email'])->toBe('som***com')
->and($summary['items']['_truncated'])->toBe('... and 2 more items');
});
test('DataRedactor_redact_Ugly_stops_when_the_maximum_depth_is_exceeded', function (): void {
$service = new DataRedactor;
$redacted = $service->redact([
'level1' => [
'level2' => [
'level3' => ['secret' => 'value'],
],
],
], 2);
expect($redacted['level1']['level2'])->toBe('[MAX_DEPTH_EXCEEDED]');
});

View file

@ -0,0 +1,99 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
require_once dirname(__DIR__).'/Support/bootstrap.php';
mcpRequire('Mcp/Services/McpHealthService.php');
use Core\Mcp\Services\McpHealthService;
use Illuminate\Support\Facades\Cache;
beforeEach(function (): void {
Cache::flush();
});
test('McpHealthService_check_Good_marks_a_stdio_server_online_when_initialize_returns_jsonrpc_result', function (): void {
$service = new class extends McpHealthService
{
protected function loadServerConfig(string $serverId): ?array
{
return [
'id' => $serverId,
'connection' => [
'type' => 'stdio',
'command' => 'php',
'args' => ['artisan', 'mcp:agent-server'],
'cwd' => '${APP_ROOT:-/tmp}',
],
];
}
protected function executeProcess(array $command, string $cwd, string $input): array
{
return [
'exit_code' => 0,
'output' => json_encode([
'jsonrpc' => '2.0',
'result' => [
'serverInfo' => ['name' => 'Host Hub'],
'protocolVersion' => '2024-11-05',
],
]),
'error' => '',
'response_time_ms' => 42,
];
}
};
$result = $service->check('host-hub', true);
expect($result['status'])->toBe(McpHealthService::STATUS_ONLINE)
->and($result['protocol_version'])->toBe('2024-11-05')
->and($result['response_time_ms'])->toBe(42);
});
test('McpHealthService_check_Bad_returns_unknown_for_missing_registry_entries', function (): void {
$service = new class extends McpHealthService
{
protected function loadServerConfig(string $serverId): ?array
{
return null;
}
};
expect($service->check('missing', true)['status'])->toBe(McpHealthService::STATUS_UNKNOWN);
});
test('McpHealthService_check_Ugly_marks_successful_but_malformed_stdio_output_as_degraded', function (): void {
$service = new class extends McpHealthService
{
protected function loadServerConfig(string $serverId): ?array
{
return [
'id' => $serverId,
'connection' => [
'type' => 'stdio',
'command' => 'php',
],
];
}
protected function executeProcess(array $command, string $cwd, string $input): array
{
return [
'exit_code' => 0,
'output' => "not-json\nstill-not-json",
'error' => '',
'response_time_ms' => 15,
];
}
};
$result = $service->check('marketing', true);
expect($result['status'])->toBe(McpHealthService::STATUS_DEGRADED)
->and($result['output'])->toContain('not-json');
});

View file

@ -0,0 +1,100 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
require_once dirname(__DIR__).'/Support/bootstrap.php';
mcpRequire('Mcp/Services/McpMetricsService.php');
use Carbon\CarbonImmutable;
use Core\Mcp\Services\McpMetricsService;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
beforeEach(function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-04-25 12:00:00'));
Schema::dropIfExists('mcp_tool_call_stats');
Schema::dropIfExists('mcp_tool_calls');
Schema::create('mcp_tool_call_stats', function (Blueprint $table): void {
$table->id();
$table->date('date');
$table->string('server_id');
$table->string('tool_name');
$table->unsignedInteger('call_count')->default(0);
$table->unsignedInteger('success_count')->default(0);
$table->unsignedInteger('error_count')->default(0);
$table->unsignedInteger('total_duration_ms')->default(0);
$table->timestamps();
});
Schema::create('mcp_tool_calls', function (Blueprint $table): void {
$table->id();
$table->string('server_id');
$table->string('tool_name');
$table->string('session_id')->nullable();
$table->boolean('success')->default(true);
$table->unsignedInteger('duration_ms')->nullable();
$table->string('error_message')->nullable();
$table->string('error_code')->nullable();
$table->string('plan_slug')->nullable();
$table->timestamps();
});
});
afterEach(function (): void {
CarbonImmutable::setTestNow();
});
test('McpMetricsService_getOverview_Good_returns_current_period_dashboard_metrics', function (): void {
DB::table('mcp_tool_call_stats')->insert([
['date' => '2026-04-24', 'server_id' => 'host-hub', 'tool_name' => 'send_email', 'call_count' => 10, 'success_count' => 9, 'error_count' => 1, 'total_duration_ms' => 1000, 'created_at' => now(), 'updated_at' => now()],
['date' => '2026-04-25', 'server_id' => 'marketing', 'tool_name' => 'list_posts', 'call_count' => 5, 'success_count' => 5, 'error_count' => 0, 'total_duration_ms' => 750, 'created_at' => now(), 'updated_at' => now()],
]);
$service = new McpMetricsService;
$overview = $service->getOverview(2);
$trend = $service->getDailyTrend(2);
expect($overview['total_calls'])->toBe(15)
->and($overview['success_rate'])->toBe(93.3)
->and($trend[0]['date'])->toBe('2026-04-24')
->and($trend[1]['total_calls'])->toBe(5);
});
test('McpMetricsService_getErrorBreakdown_Bad_groups_errors_and_plan_activity_from_raw_calls', function (): void {
DB::table('mcp_tool_calls')->insert([
['server_id' => 'host-hub', 'tool_name' => 'send_email', 'success' => false, 'duration_ms' => 100, 'error_message' => 'Bad gateway', 'error_code' => '502', 'plan_slug' => 'plan-a', 'created_at' => now(), 'updated_at' => now()],
['server_id' => 'host-hub', 'tool_name' => 'send_email', 'success' => false, 'duration_ms' => 120, 'error_message' => 'Bad gateway', 'error_code' => '502', 'plan_slug' => 'plan-a', 'created_at' => now(), 'updated_at' => now()],
['server_id' => 'marketing', 'tool_name' => 'list_posts', 'success' => true, 'duration_ms' => 80, 'plan_slug' => 'plan-b', 'created_at' => now(), 'updated_at' => now()],
]);
$service = new McpMetricsService;
$errors = $service->getErrorBreakdown(7);
$plans = $service->getPlanActivity(7);
expect($errors[0]['tool_name'])->toBe('send_email')
->and($errors[0]['error_count'])->toBe(2)
->and($plans[0]['plan_slug'])->toBe('plan-a')
->and($plans[0]['success_rate'])->toBe(0.0);
});
test('McpMetricsService_getToolPerformance_Ugly_uses_linear_interpolation_for_percentiles', function (): void {
DB::table('mcp_tool_calls')->insert([
['server_id' => 'host-hub', 'tool_name' => 'send_email', 'success' => true, 'duration_ms' => 100, 'created_at' => now(), 'updated_at' => now()],
['server_id' => 'host-hub', 'tool_name' => 'send_email', 'success' => true, 'duration_ms' => 200, 'created_at' => now(), 'updated_at' => now()],
['server_id' => 'host-hub', 'tool_name' => 'send_email', 'success' => true, 'duration_ms' => 300, 'created_at' => now(), 'updated_at' => now()],
['server_id' => 'host-hub', 'tool_name' => 'send_email', 'success' => true, 'duration_ms' => 400, 'created_at' => now(), 'updated_at' => now()],
]);
$service = new McpMetricsService;
$performance = $service->getToolPerformance(7, 1)->first();
expect($performance['p50_ms'])->toBe(250.0)
->and($performance['p95_ms'])->toBe(385.0)
->and($performance['p99_ms'])->toBe(397.0);
});

View file

@ -0,0 +1,187 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
require_once dirname(__DIR__).'/Support/bootstrap.php';
mcpRequire('Mcp/Services/McpWebhookDispatcher.php');
use Core\Mcp\Services\McpWebhookDispatcher;
use Illuminate\Support\Facades\Http;
final class McpWebhookDispatcherEndpointBuilderStub
{
public function __construct(private array $records)
{
}
public function getModel(): object
{
return new McpWebhookDispatcherEndpointStub([]);
}
public function forWorkspace(int $workspaceId): self
{
$this->records = array_values(array_filter(
$this->records,
static fn (McpWebhookDispatcherEndpointStub $endpoint): bool => $endpoint->workspace_id === $workspaceId,
));
return $this;
}
public function active(): self
{
$this->records = array_values(array_filter(
$this->records,
static fn (McpWebhookDispatcherEndpointStub $endpoint): bool => $endpoint->active,
));
return $this;
}
public function forEvent(string $eventType): self
{
$this->records = array_values(array_filter(
$this->records,
static fn (McpWebhookDispatcherEndpointStub $endpoint): bool => in_array($eventType, $endpoint->events, true),
));
return $this;
}
public function get(): array
{
return $this->records;
}
}
final class McpWebhookDispatcherEndpointStub
{
public static array $records = [];
public function __construct(
public array $events,
public int $workspace_id = 1,
public bool $active = true,
public string $url = 'https://hooks.example.test/mcp',
public int $id = 1,
public int $successes = 0,
public int $failures = 0,
) {
}
public static function query(): McpWebhookDispatcherEndpointBuilderStub
{
return new McpWebhookDispatcherEndpointBuilderStub(static::$records);
}
public function generateSignature(string $payload): string
{
return 'sig:'.sha1($payload);
}
public function recordSuccess(): void
{
$this->successes++;
}
public function recordFailure(): void
{
$this->failures++;
}
}
final class McpWebhookDispatcherDeliveryStub
{
public static array $records = [];
public static function create(array $attributes): void
{
static::$records[] = $attributes;
}
}
beforeEach(function (): void {
Http::preventStrayRequests();
McpWebhookDispatcherEndpointStub::$records = [];
McpWebhookDispatcherDeliveryStub::$records = [];
});
test('McpWebhookDispatcher_dispatchToolExecuted_Good_delivers_to_matching_endpoints_and_records_success', function (): void {
McpWebhookDispatcherEndpointStub::$records = [
new McpWebhookDispatcherEndpointStub(['mcp.tool.executed']),
];
Http::fake([
'https://hooks.example.test/mcp' => Http::response('ok', 200),
]);
$dispatcher = new class extends McpWebhookDispatcher
{
protected function endpointModelClass(): ?string
{
return McpWebhookDispatcherEndpointStub::class;
}
protected function deliveryModelClass(): ?string
{
return McpWebhookDispatcherDeliveryStub::class;
}
};
$dispatcher->dispatchToolExecuted(1, 'host-hub', 'send_email', ['to' => 'a@example.com'], true, 42);
expect(McpWebhookDispatcherDeliveryStub::$records)->toHaveCount(1)
->and(McpWebhookDispatcherDeliveryStub::$records[0]['status'])->toBe('success')
->and(McpWebhookDispatcherEndpointStub::$records[0]->successes)->toBe(1);
});
test('McpWebhookDispatcher_dispatchToolExecuted_Bad_noops_when_no_endpoints_are_subscribed', function (): void {
$dispatcher = new class extends McpWebhookDispatcher
{
protected function endpointModelClass(): ?string
{
return McpWebhookDispatcherEndpointStub::class;
}
protected function deliveryModelClass(): ?string
{
return McpWebhookDispatcherDeliveryStub::class;
}
};
$dispatcher->dispatchToolExecuted(1, 'host-hub', 'send_email', [], true, 10);
expect(McpWebhookDispatcherDeliveryStub::$records)->toHaveCount(0);
});
test('McpWebhookDispatcher_dispatchToolExecuted_Ugly_records_failed_deliveries_and_failure_counts', function (): void {
McpWebhookDispatcherEndpointStub::$records = [
new McpWebhookDispatcherEndpointStub(['mcp.tool.executed']),
];
Http::fake([
'https://hooks.example.test/mcp' => Http::response('broken', 500),
]);
$dispatcher = new class extends McpWebhookDispatcher
{
protected function endpointModelClass(): ?string
{
return McpWebhookDispatcherEndpointStub::class;
}
protected function deliveryModelClass(): ?string
{
return McpWebhookDispatcherDeliveryStub::class;
}
};
$dispatcher->dispatchToolExecuted(1, 'host-hub', 'send_email', [], false, 10, 'failed');
expect(McpWebhookDispatcherDeliveryStub::$records[0]['status'])->toBe('failed')
->and(McpWebhookDispatcherEndpointStub::$records[0]->failures)->toBe(1);
});

View file

@ -0,0 +1,48 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
require_once dirname(__DIR__).'/Support/bootstrap.php';
mcpRequire('Mcp/Services/OpenApiGenerator.php');
use Core\Mcp\Services\OpenApiGenerator;
use Illuminate\Support\Facades\File;
beforeEach(function (): void {
File::ensureDirectoryExists(resource_path('mcp/servers'));
});
test('OpenApiGenerator_generate_Good_builds_an_openapi_3_0_3_document_from_registry_files', function (): void {
File::put(resource_path('mcp/registry.yaml'), "servers:\n - id: host-hub\n");
File::put(resource_path('mcp/servers/host-hub.yaml'), "id: host-hub\nname: Host Hub\ntagline: Primary workspace server\n");
$generator = new OpenApiGenerator;
$document = $generator->generate();
expect($document['openapi'])->toBe('3.0.3')
->and($document['tags'][2]['name'])->toBe('Host Hub')
->and($document['paths'])->toHaveKey('/tools/call');
});
test('OpenApiGenerator_generate_Bad_falls_back_to_registry_ids_when_server_yaml_is_missing', function (): void {
File::put(resource_path('mcp/registry.yaml'), "servers:\n - id: marketing\n");
File::delete(resource_path('mcp/servers/marketing.yaml'));
$generator = new OpenApiGenerator;
$document = $generator->generate();
expect($document['tags'][2]['name'])->toBe('marketing');
});
test('OpenApiGenerator_toJson_Ugly_and_toYaml_keep_the_document_at_openapi_3_0_3_not_3_1', function (): void {
File::put(resource_path('mcp/registry.yaml'), "servers: []\n");
$generator = new OpenApiGenerator;
expect($generator->toJson())->toContain('"openapi": "3.0.3"')
->and($generator->toYaml())->toContain("openapi: 3.0.3\n")
->and($generator->toYaml())->not->toContain('3.1');
});

View file

@ -0,0 +1,54 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
require_once dirname(__DIR__).'/Support/bootstrap.php';
mcpRequire('Mcp/Services/ToolRateLimiter.php');
use Core\Mcp\Services\ToolRateLimiter;
use Illuminate\Support\Facades\Cache;
beforeEach(function (): void {
Cache::flush();
config()->set('mcp.rate_limiting.enabled', true);
config()->set('mcp.rate_limiting.decay_seconds', 60);
config()->set('mcp.rate_limiting.calls_per_minute', 2);
config()->set('mcp.rate_limiting.per_tool', ['send_email' => 1]);
});
test('ToolRateLimiter_check_Good_reports_remaining_calls_before_the_limit_is_hit', function (): void {
$limiter = new ToolRateLimiter;
$status = $limiter->check('sess-1', 'list_posts');
$limiter->hit('sess-1', 'list_posts');
$afterHit = $limiter->getStatus('sess-1', 'list_posts');
expect($status['limited'])->toBeFalse()
->and($status['remaining'])->toBe(1)
->and($afterHit['remaining'])->toBe(1);
});
test('ToolRateLimiter_check_Bad_applies_tool_specific_limits_and_returns_retry_after_when_limited', function (): void {
$limiter = new ToolRateLimiter;
$limiter->hit('sess-2', 'send_email');
$result = $limiter->check('sess-2', 'send_email');
expect($result['limited'])->toBeTrue()
->and($result['remaining'])->toBe(0)
->and($result['retry_after'])->toBeInt();
});
test('ToolRateLimiter_hit_Ugly_uses_put_for_the_first_call_and_increment_for_subsequent_calls', function (): void {
Cache::shouldReceive('get')->once()->with('mcp_rate_limit:sess-3:list_posts', 0)->andReturn(0);
Cache::shouldReceive('put')->once()->with('mcp_rate_limit:sess-3:list_posts', 1, 60);
Cache::shouldReceive('get')->once()->with('mcp_rate_limit:sess-3:list_posts', 0)->andReturn(1);
Cache::shouldReceive('increment')->once()->with('mcp_rate_limit:sess-3:list_posts');
$limiter = new ToolRateLimiter;
$limiter->hit('sess-3', 'list_posts');
$limiter->hit('sess-3', 'list_posts');
});

View file

@ -0,0 +1,62 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
function mcpRequire(string $relativePath): void
{
require_once dirname(__DIR__, 4).'/'.$relativePath;
}
function mcpDefineLaravelMcpStubs(): void
{
if (class_exists('Laravel\\Mcp\\Request') && class_exists('Laravel\\Mcp\\Response') && class_exists('Laravel\\Mcp\\Server\\Resource')) {
return;
}
eval(<<<'PHP'
namespace Laravel\Mcp {
class Request
{
public function __construct(private array $data = [])
{
}
public function get(string $key, mixed $default = null): mixed
{
return $this->data[$key] ?? $default;
}
}
class Response
{
public function __construct(
public string $type,
public string $content,
) {
}
public static function text(string $content): self
{
return new self('text', $content);
}
}
}
namespace Laravel\Mcp\Server {
abstract class Resource
{
protected string $description = '';
}
}
PHP);
}
function mcpInvokeProtected(object $object, string $method, array $arguments = []): mixed
{
$reflection = new ReflectionMethod($object, $method);
$reflection->setAccessible(true);
return $reflection->invokeArgs($object, $arguments);
}

View file

@ -0,0 +1,49 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
require_once dirname(__DIR__).'/Support/bootstrap.php';
mcpRequire('Mcp/Transport/McpContext.php');
use Core\Front\Mcp\McpContext;
test('McpContext_getters_Good_track_the_current_session_and_plan', function (): void {
$plan = (object) ['slug' => 'plan-1'];
$context = new McpContext('sess-1', $plan);
expect($context->getSessionId())->toBe('sess-1')
->and($context->getCurrentPlan())->toBe($plan)
->and($context->hasSession())->toBeTrue()
->and($context->hasPlan())->toBeTrue();
});
test('McpContext_callbacks_Bad_are_optional_and_can_be_left_unset', function (): void {
$context = new McpContext;
$context->sendNotification('mcp.progress', ['value' => 50]);
$context->logToSession('noop');
expect($context->hasSession())->toBeFalse()
->and($context->hasPlan())->toBeFalse();
});
test('McpContext_callbacks_Ugly_forward_notifications_and_session_logs_through_the_transport_hooks', function (): void {
$captured = [];
$context = new McpContext(
notificationCallback: function (string $method, array $params) use (&$captured): void {
$captured['notification'] = [$method, $params];
},
logCallback: function (string $message, string $type, array $data) use (&$captured): void {
$captured['log'] = [$message, $type, $data];
},
);
$context->sendNotification('mcp.progress', ['value' => 100]);
$context->logToSession('finished', 'info', ['ok' => true]);
expect($captured['notification'])->toBe(['mcp.progress', ['value' => 100]])
->and($captured['log'])->toBe(['finished', 'info', ['ok' => true]]);
});

View file

@ -0,0 +1,65 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
require_once dirname(__DIR__).'/Support/bootstrap.php';
mcpRequire('Mcp/Transport/McpContext.php');
mcpRequire('Mcp/Transport/Contracts/McpToolHandler.php');
use Core\Front\Mcp\Contracts\McpToolHandler;
use Core\Front\Mcp\McpContext;
test('McpToolHandler_schema_Good_can_be_implemented_with_the_expected_shape', function (): void {
$handler = new class implements McpToolHandler
{
public static function schema(): array
{
return [
'name' => 'list_posts',
'description' => 'List CMS posts',
'inputSchema' => ['type' => 'object'],
];
}
public function handle(array $args, McpContext $context): array
{
return ['ok' => true];
}
};
expect($handler::schema())->toBe([
'name' => 'list_posts',
'description' => 'List CMS posts',
'inputSchema' => ['type' => 'object'],
]);
});
test('McpToolHandler_handle_Bad_receives_the_transport_agnostic_context_object', function (): void {
$context = new McpContext('sess-1');
$handler = new class implements McpToolHandler
{
public static function schema(): array
{
return ['name' => 'ping', 'description' => 'Ping', 'inputSchema' => ['type' => 'object']];
}
public function handle(array $args, McpContext $context): array
{
return ['session_id' => $context->getSessionId()];
}
};
expect($handler->handle([], $context)['session_id'])->toBe('sess-1');
});
test('McpToolHandler_interface_Ugly_exposes_exactly_the_two_contract_methods_required_by_the_rfc', function (): void {
$methods = array_map(
static fn (ReflectionMethod $method): string => $method->getName(),
(new ReflectionClass(McpToolHandler::class))->getMethods(),
);
expect($methods)->toBe(['schema', 'handle']);
});