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:
parent
dffdad8418
commit
91551dec9b
28 changed files with 3456 additions and 0 deletions
26
php/Mcp/Resources/AppConfig.php
Normal file
26
php/Mcp/Resources/AppConfig.php
Normal 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));
|
||||
}
|
||||
}
|
||||
222
php/Mcp/Resources/ContentResource.php
Normal file
222
php/Mcp/Resources/ContentResource.php
Normal 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 ?? '')));
|
||||
}
|
||||
}
|
||||
54
php/Mcp/Resources/DatabaseSchema.php
Normal file
54
php/Mcp/Resources/DatabaseSchema.php
Normal 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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
314
php/Mcp/Services/CircuitBreaker.php
Normal file
314
php/Mcp/Services/CircuitBreaker.php
Normal 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,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
215
php/Mcp/Services/DataRedactor.php
Normal file
215
php/Mcp/Services/DataRedactor.php
Normal 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);
|
||||
}
|
||||
}
|
||||
271
php/Mcp/Services/McpHealthService.php
Normal file
271
php/Mcp/Services/McpHealthService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
351
php/Mcp/Services/McpMetricsService.php
Normal file
351
php/Mcp/Services/McpMetricsService.php
Normal 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';
|
||||
}
|
||||
}
|
||||
176
php/Mcp/Services/McpWebhookDispatcher.php
Normal file
176
php/Mcp/Services/McpWebhookDispatcher.php
Normal 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;
|
||||
}
|
||||
}
|
||||
343
php/Mcp/Services/OpenApiGenerator.php
Normal file
343
php/Mcp/Services/OpenApiGenerator.php
Normal 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'],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
117
php/Mcp/Services/ToolRateLimiter.php
Normal file
117
php/Mcp/Services/ToolRateLimiter.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
16
php/Mcp/Transport/Contracts/McpToolHandler.php
Normal file
16
php/Mcp/Transport/Contracts/McpToolHandler.php
Normal 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;
|
||||
}
|
||||
73
php/Mcp/Transport/McpContext.php
Normal file
73
php/Mcp/Transport/McpContext.php
Normal 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;
|
||||
}
|
||||
}
|
||||
249
php/Mod/Mcp/Services/AgentSessionService.php
Normal file
249
php/Mod/Mcp/Services/AgentSessionService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
3
php/resources/mcp/registry.yaml
Normal file
3
php/resources/mcp/registry.yaml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
servers:
|
||||
- id: host-hub
|
||||
- id: marketing
|
||||
44
php/tests/Feature/Mcp/Resources/AppConfigTest.php
Normal file
44
php/tests/Feature/Mcp/Resources/AppConfigTest.php
Normal 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');
|
||||
});
|
||||
83
php/tests/Feature/Mcp/Resources/ContentResourceTest.php
Normal file
83
php/tests/Feature/Mcp/Resources/ContentResourceTest.php
Normal 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',
|
||||
]]);
|
||||
});
|
||||
63
php/tests/Feature/Mcp/Resources/DatabaseSchemaTest.php
Normal file
63
php/tests/Feature/Mcp/Resources/DatabaseSchemaTest.php
Normal 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']);
|
||||
});
|
||||
52
php/tests/Feature/Mcp/Services/AgentSessionServiceTest.php
Normal file
52
php/tests/Feature/Mcp/Services/AgentSessionServiceTest.php
Normal 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');
|
||||
});
|
||||
69
php/tests/Feature/Mcp/Services/CircuitBreakerTest.php
Normal file
69
php/tests/Feature/Mcp/Services/CircuitBreakerTest.php
Normal 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);
|
||||
});
|
||||
51
php/tests/Feature/Mcp/Services/DataRedactorTest.php
Normal file
51
php/tests/Feature/Mcp/Services/DataRedactorTest.php
Normal 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]');
|
||||
});
|
||||
99
php/tests/Feature/Mcp/Services/McpHealthServiceTest.php
Normal file
99
php/tests/Feature/Mcp/Services/McpHealthServiceTest.php
Normal 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');
|
||||
});
|
||||
100
php/tests/Feature/Mcp/Services/McpMetricsServiceTest.php
Normal file
100
php/tests/Feature/Mcp/Services/McpMetricsServiceTest.php
Normal 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);
|
||||
});
|
||||
187
php/tests/Feature/Mcp/Services/McpWebhookDispatcherTest.php
Normal file
187
php/tests/Feature/Mcp/Services/McpWebhookDispatcherTest.php
Normal 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);
|
||||
});
|
||||
48
php/tests/Feature/Mcp/Services/OpenApiGeneratorTest.php
Normal file
48
php/tests/Feature/Mcp/Services/OpenApiGeneratorTest.php
Normal 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');
|
||||
});
|
||||
54
php/tests/Feature/Mcp/Services/ToolRateLimiterTest.php
Normal file
54
php/tests/Feature/Mcp/Services/ToolRateLimiterTest.php
Normal 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');
|
||||
});
|
||||
62
php/tests/Feature/Mcp/Support/bootstrap.php
Normal file
62
php/tests/Feature/Mcp/Support/bootstrap.php
Normal 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);
|
||||
}
|
||||
49
php/tests/Feature/Mcp/Transport/McpContextTest.php
Normal file
49
php/tests/Feature/Mcp/Transport/McpContextTest.php
Normal 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]]);
|
||||
});
|
||||
65
php/tests/Feature/Mcp/Transport/McpToolHandlerTest.php
Normal file
65
php/tests/Feature/Mcp/Transport/McpToolHandlerTest.php
Normal 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']);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue