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