diff --git a/config/mcp.php b/config/mcp.php index 5165ce1a..b3e61423 100644 --- a/config/mcp.php +++ b/config/mcp.php @@ -20,4 +20,17 @@ // 'https://example.com', ], + /* + |-------------------------------------------------------------------------- + | Session Time To Live (TTL) + |-------------------------------------------------------------------------- + | + | This value determines how long (in seconds) MCP session data will be + | cached. Session data includes log level preferences and another + | per-session state. The default is 86,400 seconds (24 hours). + | + */ + + 'session_ttl' => env('MCP_SESSION_TTL', 86400), + ]; diff --git a/src/Enums/LogLevel.php b/src/Enums/LogLevel.php new file mode 100644 index 00000000..14a776fd --- /dev/null +++ b/src/Enums/LogLevel.php @@ -0,0 +1,41 @@ + 0, + self::Alert => 1, + self::Critical => 2, + self::Error => 3, + self::Warning => 4, + self::Notice => 5, + self::Info => 6, + self::Debug => 7, + }; + } + + public function shouldLog(LogLevel $configuredLevel): bool + { + return $this->severity() <= $configuredLevel->severity(); + } + + public static function fromString(string $level): self + { + return self::from(strtolower($level)); + } +} diff --git a/src/Response.php b/src/Response.php index a579c811..7dd281aa 100644 --- a/src/Response.php +++ b/src/Response.php @@ -8,9 +8,11 @@ use Illuminate\Support\Traits\Macroable; use InvalidArgumentException; use JsonException; +use Laravel\Mcp\Enums\LogLevel; use Laravel\Mcp\Enums\Role; use Laravel\Mcp\Exceptions\NotImplementedException; use Laravel\Mcp\Server\Content\Blob; +use Laravel\Mcp\Server\Content\Log; use Laravel\Mcp\Server\Content\Notification; use Laravel\Mcp\Server\Content\Text; use Laravel\Mcp\Server\Contracts\Content; @@ -36,6 +38,17 @@ public static function notification(string $method, array $params = []): static return new static(new Notification($method, $params)); } + public static function log(LogLevel $level, mixed $data, ?string $logger = null): static + { + try { + json_encode($data, JSON_THROW_ON_ERROR); + } catch (JsonException $jsonException) { + throw new InvalidArgumentException("Invalid log data: {$jsonException->getMessage()}", 0, $jsonException); + } + + return new static(new Log($level, $data, $logger)); + } + public static function text(string $text): static { return new static(new Text($text)); diff --git a/src/Server.php b/src/Server.php index f3481bca..daffefb2 100644 --- a/src/Server.php +++ b/src/Server.php @@ -18,6 +18,7 @@ use Laravel\Mcp\Server\Methods\ListTools; use Laravel\Mcp\Server\Methods\Ping; use Laravel\Mcp\Server\Methods\ReadResource; +use Laravel\Mcp\Server\Methods\SetLogLevel; use Laravel\Mcp\Server\Prompt; use Laravel\Mcp\Server\Resource; use Laravel\Mcp\Server\ServerContext; @@ -53,17 +54,25 @@ abstract class Server '2024-11-05', ]; + public const CAPABILITY_TOOLS = 'tools'; + + public const CAPABILITY_RESOURCES = 'resources'; + + public const CAPABILITY_PROMPTS = 'prompts'; + + public const CAPABILITY_LOGGING = 'logging'; + /** * @var array|stdClass|string> */ protected array $capabilities = [ - 'tools' => [ + self::CAPABILITY_TOOLS => [ 'listChanged' => false, ], - 'resources' => [ + self::CAPABILITY_RESOURCES => [ 'listChanged' => false, ], - 'prompts' => [ + self::CAPABILITY_PROMPTS => [ 'listChanged' => false, ], ]; @@ -99,6 +108,7 @@ abstract class Server 'prompts/list' => ListPrompts::class, 'prompts/get' => GetPrompt::class, 'ping' => Ping::class, + 'logging/setLevel' => SetLogLevel::class, ]; public function __construct( @@ -231,19 +241,23 @@ public function createContext(): ServerContext */ protected function handleMessage(JsonRpcRequest $request, ServerContext $context): void { - $response = $this->runMethodHandle($request, $context); - - if (! is_iterable($response)) { - $this->transport->send($response->toJson()); + try { + $response = $this->runMethodHandle($request, $context); - return; - } + if (! is_iterable($response)) { + $this->transport->send($response->toJson()); - $this->transport->stream(function () use ($response): void { - foreach ($response as $message) { - $this->transport->send($message->toJson()); + return; } - }); + + $this->transport->stream(function () use ($response): void { + foreach ($response as $message) { + $this->transport->send($message->toJson()); + } + }); + } finally { + Container::getInstance()->forgetInstance('mcp.request'); + } } /** @@ -255,20 +269,14 @@ protected function runMethodHandle(JsonRpcRequest $request, ServerContext $conte { $container = Container::getInstance(); + $container->instance('mcp.request', $request->toRequest()); + /** @var Method $methodClass */ $methodClass = $container->make( $this->methods[$request->method], ); - $container->instance('mcp.request', $request->toRequest()); - - try { - $response = $methodClass->handle($request, $context); - } finally { - $container->forgetInstance('mcp.request'); - } - - return $response; + return $methodClass->handle($request, $context); } protected function handleInitializeMessage(JsonRpcRequest $request, ServerContext $context): void diff --git a/src/Server/Content/Log.php b/src/Server/Content/Log.php new file mode 100644 index 00000000..f53bc27b --- /dev/null +++ b/src/Server/Content/Log.php @@ -0,0 +1,50 @@ +buildParams()); + } + + public function level(): LogLevel + { + return $this->level; + } + + public function data(): mixed + { + return $this->data; + } + + public function logger(): ?string + { + return $this->logger; + } + + /** + * @return array + */ + protected function buildParams(): array + { + $params = [ + 'level' => $this->level->value, + 'data' => $this->data, + ]; + + if ($this->logger !== null) { + $params['logger'] = $this->logger; + } + + return $params; + } +} diff --git a/src/Server/McpServiceProvider.php b/src/Server/McpServiceProvider.php index 4bbd9af9..46a54e44 100644 --- a/src/Server/McpServiceProvider.php +++ b/src/Server/McpServiceProvider.php @@ -4,6 +4,7 @@ namespace Laravel\Mcp\Server; +use Illuminate\Contracts\Cache\Repository; use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; use Laravel\Mcp\Console\Commands\InspectorCommand; @@ -13,6 +14,8 @@ use Laravel\Mcp\Console\Commands\MakeToolCommand; use Laravel\Mcp\Console\Commands\StartCommand; use Laravel\Mcp\Request; +use Laravel\Mcp\Server\Support\LoggingManager; +use Laravel\Mcp\Server\Support\SessionStore; class McpServiceProvider extends ServiceProvider { @@ -21,6 +24,8 @@ public function register(): void $this->app->singleton(Registrar::class, fn (): Registrar => new Registrar); $this->mergeConfigFrom(__DIR__.'/../../config/mcp.php', 'mcp'); + + $this->registerSessionBindings(); } public function boot(): void @@ -85,6 +90,28 @@ protected function registerContainerCallbacks(): void }); } + protected function registerSessionBindings(): void + { + $this->app->bind(SessionStore::class, function ($app): Support\SessionStore { + $sessionId = null; + + if ($app->bound('mcp.request')) { + /** @var Request $request */ + $request = $app->make('mcp.request'); + $sessionId = $request->sessionId(); + } + + return new SessionStore( + $app->make(Repository::class), + $sessionId + ); + }); + + $this->app->bind(LoggingManager::class, fn ($app): LoggingManager => new LoggingManager( + $app->make(SessionStore::class) + )); + } + protected function registerCommands(): void { $this->commands([ diff --git a/src/Server/Methods/Concerns/InteractsWithResponses.php b/src/Server/Methods/Concerns/InteractsWithResponses.php index 75e8b9fb..2c603918 100644 --- a/src/Server/Methods/Concerns/InteractsWithResponses.php +++ b/src/Server/Methods/Concerns/InteractsWithResponses.php @@ -9,9 +9,11 @@ use Illuminate\Validation\ValidationException; use Laravel\Mcp\Response; use Laravel\Mcp\ResponseFactory; +use Laravel\Mcp\Server\Content\Log; use Laravel\Mcp\Server\Content\Notification; use Laravel\Mcp\Server\Contracts\Errable; use Laravel\Mcp\Server\Exceptions\JsonRpcException; +use Laravel\Mcp\Server\Support\LoggingManager; use Laravel\Mcp\Server\Transport\JsonRpcRequest; use Laravel\Mcp\Server\Transport\JsonRpcResponse; @@ -45,6 +47,7 @@ protected function toJsonRpcStreamedResponse(JsonRpcRequest $request, iterable $ { /** @var array $pendingResponses */ $pendingResponses = []; + $loggingManager = null; try { foreach ($responses as $response) { @@ -52,6 +55,14 @@ protected function toJsonRpcStreamedResponse(JsonRpcRequest $request, iterable $ /** @var Notification $content */ $content = $response->content(); + if ($content instanceof Log) { + $loggingManager ??= app(LoggingManager::class); + + if (! $loggingManager->shouldLog($content->level())) { + continue; + } + } + yield JsonRpcResponse::notification( ...$content->toArray(), ); diff --git a/src/Server/Methods/SetLogLevel.php b/src/Server/Methods/SetLogLevel.php new file mode 100644 index 00000000..6fa0d0ba --- /dev/null +++ b/src/Server/Methods/SetLogLevel.php @@ -0,0 +1,60 @@ +hasCapability(Server::CAPABILITY_LOGGING)) { + throw new JsonRpcException( + 'Logging capability is not enabled on this server.', + -32601, + $request->id, + ); + } + + $levelString = $request->get('level'); + + if (! is_string($levelString)) { + throw new JsonRpcException( + 'Invalid Request: The [level] parameter is required and must be a string.', + -32602, + $request->id, + ); + } + + try { + $level = LogLevel::fromString($levelString); + } catch (ValueError) { + $validLevels = implode(', ', array_column(LogLevel::cases(), 'value')); + + throw new JsonRpcException( + "Invalid log level [{$levelString}]. Must be one of: {$validLevels}.", + -32602, + $request->id, + ); + } + + $this->loggingManager->setLevel($level); + + return JsonRpcResponse::result($request->id, []); + } +} diff --git a/src/Server/ServerContext.php b/src/Server/ServerContext.php index ab420e67..18bfd999 100644 --- a/src/Server/ServerContext.php +++ b/src/Server/ServerContext.php @@ -83,6 +83,11 @@ public function perPage(?int $requestedPerPage = null): int return min($requestedPerPage ?? $this->defaultPaginationLength, $this->maxPaginationLength); } + public function hasCapability(string $capability): bool + { + return array_key_exists($capability, $this->serverCapabilities); + } + /** * @template T of Primitive * diff --git a/src/Server/Support/LoggingManager.php b/src/Server/Support/LoggingManager.php new file mode 100644 index 00000000..fb450910 --- /dev/null +++ b/src/Server/Support/LoggingManager.php @@ -0,0 +1,38 @@ +session->set(self::LOG_LEVEL_KEY, $level); + } + + public function getLevel(): LogLevel + { + if (is_null($this->session->sessionId())) { + return self::DEFAULT_LEVEL; + } + + return $this->session->get(self::LOG_LEVEL_KEY, self::DEFAULT_LEVEL); + } + + public function shouldLog(LogLevel $messageLevel): bool + { + return $messageLevel->shouldLog($this->getLevel()); + } +} diff --git a/src/Server/Support/SessionStore.php b/src/Server/Support/SessionStore.php new file mode 100644 index 00000000..50213916 --- /dev/null +++ b/src/Server/Support/SessionStore.php @@ -0,0 +1,66 @@ +ttl ??= config('mcp.session_ttl', 86400); + } + + public function set(string $key, mixed $value): void + { + if (is_null($this->sessionId)) { + return; + } + + $this->cache->put($this->cacheKey($key), $value, $this->ttl); + } + + public function get(string $key, mixed $default = null): mixed + { + if (is_null($this->sessionId)) { + return $default; + } + + return $this->cache->get($this->cacheKey($key), $default); + } + + public function has(string $key): bool + { + if (is_null($this->sessionId)) { + return false; + } + + return $this->cache->has($this->cacheKey($key)); + } + + public function forget(string $key): void + { + if (is_null($this->sessionId)) { + return; + } + + $this->cache->forget($this->cacheKey($key)); + } + + public function sessionId(): ?string + { + return $this->sessionId; + } + + protected function cacheKey(string $key): string + { + return self::CACHE_PREFIX.":{$this->sessionId}:{$key}"; + } +} diff --git a/src/Server/Testing/PendingTestResponse.php b/src/Server/Testing/PendingTestResponse.php index e452d091..987d9fc8 100644 --- a/src/Server/Testing/PendingTestResponse.php +++ b/src/Server/Testing/PendingTestResponse.php @@ -99,6 +99,10 @@ protected function run(string $method, Primitive|string $primitive, array $argum $response = $jsonRpcException->toJsonRpcResponse(); } - return new TestResponse($primitive, $response); + try { + return new TestResponse($primitive, $response); + } finally { + Container::getInstance()->forgetInstance('mcp.request'); + } } } diff --git a/src/Server/Testing/TestResponse.php b/src/Server/Testing/TestResponse.php index 8a797224..f3c73f98 100644 --- a/src/Server/Testing/TestResponse.php +++ b/src/Server/Testing/TestResponse.php @@ -6,8 +6,11 @@ use Illuminate\Container\Container; use Illuminate\Contracts\Auth\Authenticatable; +use Illuminate\Support\Arr; +use Illuminate\Support\Collection; use Illuminate\Support\Traits\Conditionable; use Illuminate\Support\Traits\Macroable; +use Laravel\Mcp\Enums\LogLevel; use Laravel\Mcp\Server\Primitive; use Laravel\Mcp\Server\Prompt; use Laravel\Mcp\Server\Resource; @@ -234,6 +237,76 @@ protected function isAuthenticated(?string $guard = null): bool return Container::getInstance()->make('auth')->guard($guard)->check(); } + public function assertLogSent(LogLevel $level, ?string $contains = null): static + { + if ($this->findLogNotification($level, $contains)) { + Assert::assertTrue(true); // @phpstan-ignore-line + + return $this; + } + + $levelName = $level->value; + $containsMsg = $contains !== null ? " containing [{$contains}]" : ''; + Assert::fail("The expected log notification with level [{$levelName}]{$containsMsg} was not found."); + } + + public function assertLogNotSent(LogLevel $level): static + { + if ($this->findLogNotification($level)) { + $levelName = $level->value; + Assert::fail("The log notification with level [{$levelName}] was unexpectedly found."); + } + + Assert::assertTrue(true); // @phpstan-ignore-line + + return $this; + } + + public function assertLogCount(int $count): static + { + $logNotifications = $this->getLogNotifications(); + + Assert::assertCount( + $count, + $logNotifications, + "The expected number of log notifications [{$count}] does not match the actual count [{$logNotifications->count()}]." + ); + + return $this; + } + + /** + * @return \Illuminate\Support\Collection> + */ + protected function getLogNotifications(): Collection + { + return collect($this->notifications) + ->map(fn (JsonRpcResponse $notification): array => $notification->toArray()) + ->filter(fn (array $content): bool => $content['method'] === 'notifications/message' + && isset($content['params']['level']) + ); + } + + protected function findLogNotification(LogLevel $level, ?string $contains = null): bool + { + return $this->getLogNotifications()->contains(function (array $notification) use ($level, $contains): bool { + $params = $notification['params'] ?? []; + + if (($params['level'] ?? null) !== $level->value) { + return false; + } + + if ($contains === null) { + return true; + } + + $data = Arr::get($params, 'data', ''); + $dataString = is_string($data) ? $data : (string) json_encode($data); + + return $dataString !== '' && str_contains($dataString, $contains); + }); + } + public function dd(): void { dd($this->response->toArray()); diff --git a/tests/Feature/Logging/LogFilteringTest.php b/tests/Feature/Logging/LogFilteringTest.php new file mode 100644 index 00000000..7bd970b9 --- /dev/null +++ b/tests/Feature/Logging/LogFilteringTest.php @@ -0,0 +1,191 @@ + [ + 'listChanged' => false, + ], + self::CAPABILITY_RESOURCES => [ + 'listChanged' => false, + ], + self::CAPABILITY_PROMPTS => [ + 'listChanged' => false, + ], + self::CAPABILITY_LOGGING => [], + ]; +} + +class LoggingTestTool extends Tool +{ + public function handle(Request $request): \Generator + { + yield Response::log(LogLevel::Emergency, 'Emergency message'); + yield Response::log(LogLevel::Error, 'Error message'); + yield Response::log(LogLevel::Warning, 'Warning message'); + yield Response::log(LogLevel::Info, 'Info message'); + yield Response::log(LogLevel::Debug, 'Debug message'); + + yield Response::text('Processing complete'); + } +} + +class StructuredLogTool extends Tool +{ + public function handle(Request $request): \Generator + { + yield Response::log( + LogLevel::Error, + ['error' => 'Connection failed', 'host' => 'localhost', 'port' => 5432], + 'database' + ); + + yield Response::log( + LogLevel::Info, + 'Query executed successfully', + 'database' + ); + + yield Response::text('Database operation complete'); + } +} + +class LogLevelTestTool extends Tool +{ + public function handle(Request $request, LoggingManager $logManager): \Generator + { + yield Response::log(LogLevel::Warning, 'This is a warning message'); + yield Response::log(LogLevel::Emergency, 'This is an emergency message'); + + $level = $logManager->getLevel(); + yield Response::text('Here is the Log Level: '.$level->value); + } +} + +it('sends all log levels with the default info level', function (): void { + $response = LoggingTestServer::tool(LoggingTestTool::class); + + $response->assertLogCount(4) + ->assertLogSent(LogLevel::Emergency, 'Emergency message') + ->assertLogSent(LogLevel::Error, 'Error message') + ->assertLogSent(LogLevel::Warning, 'Warning message') + ->assertLogSent(LogLevel::Info, 'Info message') + ->assertLogNotSent(LogLevel::Debug); +}); + +it('handles structured log data with arrays', function (): void { + $response = LoggingTestServer::tool(StructuredLogTool::class); + + $response->assertLogCount(2) + ->assertLogSent(LogLevel::Error) + ->assertLogSent(LogLevel::Info); +}); + +it('supports string and array data in logs', function (): void { + $response = LoggingTestServer::tool(StructuredLogTool::class); + + $response->assertSentNotification('notifications/message') + ->assertLogCount(2); +}); + +it('filters logs correctly when the log level is set to critical', function (): void { + $transport = new ArrayTransport; + $server = new LoggingTestServer($transport); + $server->start(); + + $sessionId = 'test-session-'.uniqid(); + $transport->sessionId = $sessionId; + + ($transport->handler)(json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'logging/setLevel', + 'params' => ['level' => 'critical'], + ])); + + ($transport->handler)(json_encode([ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'log-level-test-tool', + 'arguments' => [], + ], + ])); + + $logNotifications = collect($transport->sent) + ->map(fn ($msg): mixed => json_decode((string) $msg, true)) + ->filter(fn ($msg): bool => isset($msg['method']) && $msg['method'] === 'notifications/message') + ->filter(fn ($msg): bool => isset($msg['params']['level'])); + + expect($logNotifications->count())->toBe(1); + + $emergencyLog = $logNotifications->first(fn ($msg): bool => $msg['params']['level'] === 'emergency'); + expect($emergencyLog)->not->toBeNull(); + expect($emergencyLog['params']['data'])->toBe('This is an emergency message'); + + $warningLog = $logNotifications->first(fn ($msg): bool => $msg['params']['level'] === 'warning'); + expect($warningLog)->toBeNull(); + + $toolResponse = collect($transport->sent) + ->map(fn ($msg): mixed => json_decode((string) $msg, true)) + ->first(fn ($msg): bool => isset($msg['id']) && $msg['id'] === 2); + + expect($toolResponse['result']['content'][0]['text'])->toContain('critical'); +}); + +it('filters logs correctly with default info log level', function (): void { + $transport = new ArrayTransport; + $server = new LoggingTestServer($transport); + $server->start(); + + $sessionId = 'test-session-'.uniqid(); + $transport->sessionId = $sessionId; + + ($transport->handler)(json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'log-level-test-tool', + 'arguments' => [], + ], + ])); + + $logNotifications = collect($transport->sent) + ->map(fn ($msg): mixed => json_decode((string) $msg, true)) + ->filter(fn ($msg): bool => isset($msg['method']) && $msg['method'] === 'notifications/message') + ->filter(fn ($msg): bool => isset($msg['params']['level'])); + + expect($logNotifications->count())->toBe(2); + + $emergencyLog = $logNotifications->first(fn ($msg): bool => $msg['params']['level'] === 'emergency'); + expect($emergencyLog)->not->toBeNull(); + expect($emergencyLog['params']['data'])->toBe('This is an emergency message'); + + $warningLog = $logNotifications->first(fn ($msg): bool => $msg['params']['level'] === 'warning'); + expect($warningLog)->not->toBeNull(); + expect($warningLog['params']['data'])->toBe('This is a warning message'); + + $toolResponse = collect($transport->sent) + ->map(fn ($msg): mixed => json_decode((string) $msg, true)) + ->first(fn ($msg): bool => isset($msg['id']) && $msg['id'] === 1); + + expect($toolResponse['result']['content'][0]['text'])->toContain('info'); +}); diff --git a/tests/Feature/Logging/SetLogLevelTest.php b/tests/Feature/Logging/SetLogLevelTest.php new file mode 100644 index 00000000..9a5f2bf5 --- /dev/null +++ b/tests/Feature/Logging/SetLogLevelTest.php @@ -0,0 +1,75 @@ +start(); + + $sessionId = 'test-session-'.uniqid(); + $transport->sessionId = $sessionId; + + $payload = json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'logging/setLevel', + 'params' => [ + 'level' => 'error', + ], + ]); + + ($transport->handler)($payload); + + $response = json_decode((string) $transport->sent[0], true); + + expect($response)->toHaveKey('result') + ->and($response['id'])->toBe(1); + + $manager = new LoggingManager(new SessionStore(Cache::driver(), $sessionId)); + + expect($manager->getLevel())->toBe(LogLevel::Error); +}); + +it('correctly isolates log levels per session', function (): void { + $transport1 = new ArrayTransport; + $server1 = new ExampleServer($transport1); + $server1->start(); + + $transport2 = new ArrayTransport; + $server2 = new ExampleServer($transport2); + $server2->start(); + + $sessionId1 = 'session-1-'.uniqid(); + $sessionId2 = 'session-2-'.uniqid(); + + $transport1->sessionId = $sessionId1; + ($transport1->handler)(json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'logging/setLevel', + 'params' => ['level' => 'debug'], + ])); + + $transport2->sessionId = $sessionId2; + ($transport2->handler)(json_encode([ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'logging/setLevel', + 'params' => ['level' => 'error'], + ])); + + $manager1 = new LoggingManager(new SessionStore(Cache::driver(), $sessionId1)); + $manager2 = new LoggingManager(new SessionStore(Cache::driver(), $sessionId2)); + + expect($manager1->getLevel())->toBe(LogLevel::Debug) + ->and($manager2->getLevel())->toBe(LogLevel::Error); +}); diff --git a/tests/Feature/Testing/Tools/AssertLogTest.php b/tests/Feature/Testing/Tools/AssertLogTest.php new file mode 100644 index 00000000..f5c241e1 --- /dev/null +++ b/tests/Feature/Testing/Tools/AssertLogTest.php @@ -0,0 +1,156 @@ + 'Connection failed', 'host' => 'localhost', 'port' => 5432] + ); + + yield Response::text('Done'); + } +} + +it('asserts log was sent with a specific level', function (): void { + $response = LogServer::tool(MultiLevelLogTool::class); + + $response->assertLogSent(LogLevel::Error); + $response->assertLogSent(LogLevel::Warning); + $response->assertLogSent(LogLevel::Info); +}); + +it('asserts log was sent with level and message content', function (): void { + $response = LogServer::tool(MultiLevelLogTool::class); + + $response->assertLogSent(LogLevel::Error, 'Error occurred') + ->assertLogSent(LogLevel::Warning, 'Warning message') + ->assertLogSent(LogLevel::Info, 'Info message'); +}); + +it('fails when asserting log sent with wrong level', function (): void { + $response = LogServer::tool(MultiLevelLogTool::class); + + $response->assertLogSent(LogLevel::Debug); +})->throws(AssertionFailedError::class); + +it('fails when asserting log sent with wrong message content', function (): void { + $response = LogServer::tool(MultiLevelLogTool::class); + + $response->assertLogSent(LogLevel::Error, 'Wrong message'); +})->throws(AssertionFailedError::class); + +it('asserts log was not sent', function (): void { + $response = LogServer::tool(MultiLevelLogTool::class); + + $response->assertLogNotSent(LogLevel::Debug) + ->assertLogNotSent(LogLevel::Emergency); +}); + +it('fails when asserting log not sent but it was', function (): void { + $response = LogServer::tool(MultiLevelLogTool::class); + + $response->assertLogNotSent(LogLevel::Error); +})->throws(AssertionFailedError::class); + +it('asserts the correct log count', function (): void { + $response = LogServer::tool(MultiLevelLogTool::class); + + $response->assertLogCount(3); +}); + +it('fails when asserting wrong log count', function (): void { + $response = LogServer::tool(MultiLevelLogTool::class); + + $response->assertLogCount(5); +})->throws(ExpectationFailedException::class); + +it('asserts zero logs count when no logs sent', function (): void { + $response = LogServer::tool(NoLogTool::class); + + $response->assertLogCount(0); +}); + +it('asserts a single log count', function (): void { + $response = LogServer::tool(SingleLogTool::class); + + $response->assertLogCount(1) + ->assertLogSent(LogLevel::Error, 'Single error log'); +}); + +it('asserts log sent with array data containing substring', function (): void { + $response = LogServer::tool(ArrayDataLogTool::class); + + $response->assertLogSent(LogLevel::Error, 'Connection failed') + ->assertLogSent(LogLevel::Error, 'localhost'); +}); + +it('chains multiple log assertions', function (): void { + $response = LogServer::tool(MultiLevelLogTool::class); + + $response->assertLogCount(3) + ->assertLogSent(LogLevel::Error) + ->assertLogSent(LogLevel::Warning) + ->assertLogSent(LogLevel::Info) + ->assertLogNotSent(LogLevel::Debug) + ->assertLogNotSent(LogLevel::Emergency); +}); + +it('can combine log assertions with other assertions', function (): void { + $response = LogServer::tool(MultiLevelLogTool::class); + + $response->assertSee('Done') + ->assertLogCount(3) + ->assertLogSent(LogLevel::Error); +}); diff --git a/tests/Fixtures/ExampleServer.php b/tests/Fixtures/ExampleServer.php index 926f9b28..f7a3f2c8 100644 --- a/tests/Fixtures/ExampleServer.php +++ b/tests/Fixtures/ExampleServer.php @@ -17,6 +17,19 @@ class ExampleServer extends Server RecentMeetingRecordingResource::class, ]; + protected array $capabilities = [ + self::CAPABILITY_TOOLS => [ + 'listChanged' => false, + ], + self::CAPABILITY_RESOURCES => [ + 'listChanged' => false, + ], + self::CAPABILITY_PROMPTS => [ + 'listChanged' => false, + ], + self::CAPABILITY_LOGGING => [], + ]; + protected function generateSessionId(): string { return 'overridden-'.uniqid(); diff --git a/tests/Unit/Content/LoggingMessageTest.php b/tests/Unit/Content/LoggingMessageTest.php new file mode 100644 index 00000000..2a7d4f67 --- /dev/null +++ b/tests/Unit/Content/LoggingMessageTest.php @@ -0,0 +1,126 @@ +level())->toBe(LogLevel::Error) + ->and($message->data())->toBe('Something went wrong') + ->and($message->logger())->toBeNull(); +}); + +it('creates a log with an optional logger name', function (): void { + $message = new Log(LogLevel::Info, 'Database connected', 'database'); + + expect($message->level())->toBe(LogLevel::Info) + ->and($message->data())->toBe('Database connected') + ->and($message->logger())->toBe('database'); +}); + +it('converts to array with the correct notification format', function (): void { + $withoutLogger = new Log(LogLevel::Warning, 'Low disk space'); + $withLogger = new Log(LogLevel::Debug, 'Query executed', 'sql'); + + expect($withoutLogger->toArray())->toEqual([ + 'method' => 'notifications/message', + 'params' => [ + 'level' => 'warning', + 'data' => 'Low disk space', + ], + ])->and($withLogger->toArray())->toEqual([ + 'method' => 'notifications/message', + 'params' => [ + 'level' => 'debug', + 'data' => 'Query executed', + 'logger' => 'sql', + ], + ]); +}); + +it('supports array data', function (): void { + $data = ['error' => 'Connection failed', 'host' => 'localhost', 'port' => 5432]; + $message = new Log(LogLevel::Error, $data); + + expect($message->data())->toBe($data) + ->and($message->toArray()['params']['data'])->toBe($data); +}); + +it('supports object data', function (): void { + $data = (object) ['name' => 'test', 'value' => 42]; + $message = new Log(LogLevel::Info, $data); + + expect($message->data())->toEqual($data); +}); + +it('casts to string as method name', function (): void { + $message = new Log(LogLevel::Info, 'Test message'); + + expect((string) $message)->toBe('notifications/message'); +}); + +it('may be used in primitives', function (): void { + $message = new Log(LogLevel::Info, 'Processing'); + + $tool = $message->toTool(new class extends Tool {}); + $prompt = $message->toPrompt(new class extends Prompt {}); + $resource = $message->toResource(new class extends Resource + { + protected string $uri = 'file://test.txt'; + + protected string $name = 'test'; + + protected string $title = 'Test File'; + + protected string $mimeType = 'text/plain'; + }); + + $expected = [ + 'method' => 'notifications/message', + 'params' => [ + 'level' => 'info', + 'data' => 'Processing', + ], + ]; + + expect($tool)->toEqual($expected) + ->and($prompt)->toEqual($expected) + ->and($resource)->toEqual($expected); +}); + +it('supports _meta via setMeta', function (): void { + $withMeta = new Log(LogLevel::Error, 'Error occurred'); + $withMeta->setMeta(['trace_id' => 'abc123']); + + $withoutMeta = new Log(LogLevel::Info, 'Test'); + + expect($withMeta->toArray())->toEqual([ + 'method' => 'notifications/message', + 'params' => [ + 'level' => 'error', + 'data' => 'Error occurred', + '_meta' => ['trace_id' => 'abc123'], + ], + ])->and($withoutMeta->toArray()['params'])->not->toHaveKey('_meta'); +}); + +it('supports all log levels', function (LogLevel $level, string $expected): void { + $message = new Log($level, 'Test'); + + expect($message->toArray()['params']['level'])->toBe($expected); +})->with([ + [LogLevel::Emergency, 'emergency'], + [LogLevel::Alert, 'alert'], + [LogLevel::Critical, 'critical'], + [LogLevel::Error, 'error'], + [LogLevel::Warning, 'warning'], + [LogLevel::Notice, 'notice'], + [LogLevel::Info, 'info'], + [LogLevel::Debug, 'debug'], +]); diff --git a/tests/Unit/Methods/SetLogLevelTest.php b/tests/Unit/Methods/SetLogLevelTest.php new file mode 100644 index 00000000..d2452f7a --- /dev/null +++ b/tests/Unit/Methods/SetLogLevelTest.php @@ -0,0 +1,244 @@ + '2.0', + 'id' => 1, + 'method' => 'logging/setLevel', + 'params' => [ + 'level' => 'debug', + ], + ], 'session-123'); + + $context = new ServerContext( + supportedProtocolVersions: ['2025-06-18'], + serverCapabilities: [Server::CAPABILITY_LOGGING => []], + serverName: 'Test Server', + serverVersion: '1.0.0', + instructions: 'Test instructions', + maxPaginationLength: 50, + defaultPaginationLength: 10, + tools: [], + resources: [], + prompts: [], + ); + + $loggingManager = new LoggingManager(new SessionStore(Cache::driver(), 'session-123')); + $method = new SetLogLevel($loggingManager); + + $response = $method->handle($request, $context); + + expect($response)->toBeInstanceOf(JsonRpcResponse::class); + $payload = $response->toArray(); + expect($payload['id'])->toEqual(1) + ->and($payload['result'])->toEqual((object) []); + + $manager = new LoggingManager(new SessionStore(Cache::driver(), 'session-123')); + expect($manager->getLevel())->toBe(LogLevel::Debug); +}); + +it('handles all valid log levels', function (string $levelString, LogLevel $expectedLevel): void { + $request = JsonRpcRequest::from([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'logging/setLevel', + 'params' => [ + 'level' => $levelString, + ], + ], 'session-456'); + + $context = new ServerContext( + supportedProtocolVersions: ['2025-06-18'], + serverCapabilities: [Server::CAPABILITY_LOGGING => []], + serverName: 'Test Server', + serverVersion: '1.0.0', + instructions: 'Test instructions', + maxPaginationLength: 50, + defaultPaginationLength: 10, + tools: [], + resources: [], + prompts: [], + ); + + $loggingManager = new LoggingManager(new SessionStore(Cache::driver(), 'session-456')); + $method = new SetLogLevel($loggingManager); + $response = $method->handle($request, $context); + + expect($response)->toBeInstanceOf(JsonRpcResponse::class); + + $manager = new LoggingManager(new SessionStore(Cache::driver(), 'session-456')); + expect($manager->getLevel())->toBe($expectedLevel); +})->with([ + ['emergency', LogLevel::Emergency], + ['alert', LogLevel::Alert], + ['critical', LogLevel::Critical], + ['error', LogLevel::Error], + ['warning', LogLevel::Warning], + ['notice', LogLevel::Notice], + ['info', LogLevel::Info], + ['debug', LogLevel::Debug], +]); + +it('throws an exception for a missing level parameter', function (): void { + $this->expectException(JsonRpcException::class); + $this->expectExceptionMessage('Invalid Request: The [level] parameter is required and must be a string.'); + $this->expectExceptionCode(-32602); + + $request = JsonRpcRequest::from([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'logging/setLevel', + 'params' => [], + ], 'session-789'); + + $context = new ServerContext( + supportedProtocolVersions: ['2025-06-18'], + serverCapabilities: [Server::CAPABILITY_LOGGING => []], + serverName: 'Test Server', + serverVersion: '1.0.0', + instructions: 'Test instructions', + maxPaginationLength: 50, + defaultPaginationLength: 10, + tools: [], + resources: [], + prompts: [], + ); + + $loggingManager = new LoggingManager(new SessionStore(Cache::driver(), 'session-789')); + $method = new SetLogLevel($loggingManager); + + try { + $method->handle($request, $context); + } catch (JsonRpcException $jsonRpcException) { + $error = $jsonRpcException->toJsonRpcResponse()->toArray(); + + expect($error)->toEqual([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'error' => [ + 'code' => -32602, + 'message' => 'Invalid Request: The [level] parameter is required and must be a string.', + ], + ]); + + throw $jsonRpcException; + } +}); + +it('throws exception for invalid level', function (): void { + $this->expectException(JsonRpcException::class); + $this->expectExceptionMessage('Invalid log level [invalid]'); + $this->expectExceptionCode(-32602); + + $request = JsonRpcRequest::from([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'logging/setLevel', + 'params' => [ + 'level' => 'invalid', + ], + ], 'session-999'); + + $context = new ServerContext( + supportedProtocolVersions: ['2025-06-18'], + serverCapabilities: [Server::CAPABILITY_LOGGING => []], + serverName: 'Test Server', + serverVersion: '1.0.0', + instructions: 'Test instructions', + maxPaginationLength: 50, + defaultPaginationLength: 10, + tools: [], + resources: [], + prompts: [], + ); + + $loggingManager = new LoggingManager(new SessionStore(Cache::driver(), 'session-999')); + $method = new SetLogLevel($loggingManager); + + try { + $method->handle($request, $context); + } catch (JsonRpcException $jsonRpcException) { + $error = $jsonRpcException->toJsonRpcResponse()->toArray(); + + expect($error['error']['code'])->toEqual(-32602) + ->and($error['error']['message'])->toContain('Invalid log level [invalid]'); + + throw $jsonRpcException; + } +}); + +it('throws exception for non-string level parameter', function (): void { + $this->expectException(JsonRpcException::class); + $this->expectExceptionCode(-32602); + + $request = JsonRpcRequest::from([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'logging/setLevel', + 'params' => [ + 'level' => 123, + ], + ], 'session-111'); + + $context = new ServerContext( + supportedProtocolVersions: ['2025-06-18'], + serverCapabilities: [Server::CAPABILITY_LOGGING => []], + serverName: 'Test Server', + serverVersion: '1.0.0', + instructions: 'Test instructions', + maxPaginationLength: 50, + defaultPaginationLength: 10, + tools: [], + resources: [], + prompts: [], + ); + + $loggingManager = new LoggingManager(new SessionStore(Cache::driver(), 'session-111')); + $method = new SetLogLevel($loggingManager); + $method->handle($request, $context); +}); + +it('throws an exception when logging capability is not enabled', function (): void { + $this->expectException(JsonRpcException::class); + $this->expectExceptionMessage('Logging capability is not enabled on this server.'); + $this->expectExceptionCode(-32601); + + $request = JsonRpcRequest::from([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'logging/setLevel', + 'params' => [ + 'level' => 'debug', + ], + ], 'session-222'); + + $context = new ServerContext( + supportedProtocolVersions: ['2025-06-18'], + serverCapabilities: [], + serverName: 'Test Server', + serverVersion: '1.0.0', + instructions: 'Test instructions', + maxPaginationLength: 50, + defaultPaginationLength: 10, + tools: [], + resources: [], + prompts: [], + ); + + $loggingManager = new LoggingManager(new SessionStore(Cache::driver(), 'session-222')); + $method = new SetLogLevel($loggingManager); + $method->handle($request, $context); +}); diff --git a/tests/Unit/ResponseTest.php b/tests/Unit/ResponseTest.php index 257e3d81..ef813504 100644 --- a/tests/Unit/ResponseTest.php +++ b/tests/Unit/ResponseTest.php @@ -2,11 +2,13 @@ declare(strict_types=1); +use Laravel\Mcp\Enums\LogLevel; use Laravel\Mcp\Enums\Role; use Laravel\Mcp\Exceptions\NotImplementedException; use Laravel\Mcp\Response; use Laravel\Mcp\ResponseFactory; use Laravel\Mcp\Server\Content\Blob; +use Laravel\Mcp\Server\Content\Log; use Laravel\Mcp\Server\Content\Notification; use Laravel\Mcp\Server\Content\Text; @@ -49,13 +51,13 @@ it('throws exception for audio method', function (): void { expect(function (): void { Response::audio(); - })->toThrow(NotImplementedException::class, 'The method ['.\Laravel\Mcp\Response::class.'@'.\Laravel\Mcp\Response::class.'::audio] is not implemented yet.'); + })->toThrow(NotImplementedException::class, 'The method ['.Response::class.'@'.Response::class.'::audio] is not implemented yet.'); }); it('throws exception for image method', function (): void { expect(function (): void { Response::image(); - })->toThrow(NotImplementedException::class, 'The method ['.\Laravel\Mcp\Response::class.'@'.\Laravel\Mcp\Response::class.'::image] is not implemented yet.'); + })->toThrow(NotImplementedException::class, 'The method ['.Response::class.'@'.Response::class.'::image] is not implemented yet.'); }); it('can convert response to assistant role', function (): void { @@ -177,3 +179,47 @@ InvalidArgumentException::class, ); }); + +it('creates a log response', function (): void { + $response = Response::log(LogLevel::Error, 'Something went wrong'); + + expect($response->content())->toBeInstanceOf(Log::class) + ->and($response->isNotification())->toBeTrue() + ->and($response->isError())->toBeFalse() + ->and($response->role())->toBe(Role::User); +}); + +it('creates a log response with logger name', function (): void { + $response = Response::log(LogLevel::Info, 'Query executed', 'database'); + + expect($response->content())->toBeInstanceOf(Log::class); + + $content = $response->content(); + expect($content->logger())->toBe('database'); +}); + +it('creates a log response with array data', function (): void { + $data = ['error' => 'Connection failed', 'host' => 'localhost']; + $response = Response::log(LogLevel::Error, $data); + + expect($response->content())->toBeInstanceOf(Log::class); + + $content = $response->content(); + expect($content->data())->toBe($data); +}); + +it('throws exception for invalid log data', function (): void { + $data = ['invalid' => INF]; + + expect(function () use ($data): void { + Response::log(LogLevel::Error, $data); + })->toThrow(InvalidArgumentException::class, 'Invalid log data:'); +}); + +it('creates log response with meta', function (): void { + $response = Response::log(LogLevel::Warning, 'Low memory') + ->withMeta(['trace_id' => 'abc123']); + + expect($response->content()->toArray()['params'])->toHaveKey('_meta') + ->and($response->content()->toArray()['params']['_meta'])->toEqual(['trace_id' => 'abc123']); +}); diff --git a/tests/Unit/Server/Enums/LogLevelTest.php b/tests/Unit/Server/Enums/LogLevelTest.php new file mode 100644 index 00000000..d37b0c3f --- /dev/null +++ b/tests/Unit/Server/Enums/LogLevelTest.php @@ -0,0 +1,51 @@ +severity())->toBe(0) + ->and(LogLevel::Alert->severity())->toBe(1) + ->and(LogLevel::Critical->severity())->toBe(2) + ->and(LogLevel::Error->severity())->toBe(3) + ->and(LogLevel::Warning->severity())->toBe(4) + ->and(LogLevel::Notice->severity())->toBe(5) + ->and(LogLevel::Info->severity())->toBe(6) + ->and(LogLevel::Debug->severity())->toBe(7); +}); + +it('correctly determines if a log should be sent', function (): void { + expect(LogLevel::Emergency->shouldLog(LogLevel::Info))->toBeTrue() + ->and(LogLevel::Error->shouldLog(LogLevel::Info))->toBeTrue() + ->and(LogLevel::Info->shouldLog(LogLevel::Info))->toBeTrue() + ->and(LogLevel::Debug->shouldLog(LogLevel::Info))->toBeFalse() + ->and(LogLevel::Error->shouldLog(LogLevel::Error))->toBeTrue() + ->and(LogLevel::Warning->shouldLog(LogLevel::Error))->toBeFalse() + ->and(LogLevel::Info->shouldLog(LogLevel::Error))->toBeFalse() + ->and(LogLevel::Emergency->shouldLog(LogLevel::Debug))->toBeTrue() + ->and(LogLevel::Debug->shouldLog(LogLevel::Debug))->toBeTrue() + ->and(LogLevel::Info->shouldLog(LogLevel::Debug))->toBeTrue(); + +}); + +it('can be created from string', function (): void { + expect(LogLevel::fromString('emergency'))->toBe(LogLevel::Emergency) + ->and(LogLevel::fromString('alert'))->toBe(LogLevel::Alert) + ->and(LogLevel::fromString('critical'))->toBe(LogLevel::Critical) + ->and(LogLevel::fromString('error'))->toBe(LogLevel::Error) + ->and(LogLevel::fromString('warning'))->toBe(LogLevel::Warning) + ->and(LogLevel::fromString('notice'))->toBe(LogLevel::Notice) + ->and(LogLevel::fromString('info'))->toBe(LogLevel::Info) + ->and(LogLevel::fromString('debug'))->toBe(LogLevel::Debug); +}); + +it('handles case insensitive string conversion', function (): void { + expect(LogLevel::fromString('EMERGENCY'))->toBe(LogLevel::Emergency) + ->and(LogLevel::fromString('Error'))->toBe(LogLevel::Error) + ->and(LogLevel::fromString('INFO'))->toBe(LogLevel::Info); +}); + +it('throws exception for invalid level string', function (): void { + LogLevel::fromString('invalid'); +})->throws(ValueError::class); diff --git a/tests/Unit/Server/Support/LoggingManagerTest.php b/tests/Unit/Server/Support/LoggingManagerTest.php new file mode 100644 index 00000000..db4fcc8c --- /dev/null +++ b/tests/Unit/Server/Support/LoggingManagerTest.php @@ -0,0 +1,57 @@ +getLevel())->toBe(LogLevel::Info); +}); + +test('it can set and get log level for a session', function (): void { + $manager = new LoggingManager(new SessionStore(Cache::driver(), 'session-1')); + $manager->setLevel(LogLevel::Debug); + + expect($manager->getLevel())->toBe(LogLevel::Debug); +}); + +test('it maintains separate levels for different sessions', function (): void { + $manager1 = new LoggingManager(new SessionStore(Cache::driver(), 'session-1')); + $manager2 = new LoggingManager(new SessionStore(Cache::driver(), 'session-2')); + + $manager1->setLevel(LogLevel::Debug); + $manager2->setLevel(LogLevel::Error); + + expect($manager1->getLevel())->toBe(LogLevel::Debug) + ->and($manager2->getLevel())->toBe(LogLevel::Error); +}); + +test('it correctly determines if a log should be sent', function (): void { + $manager = new LoggingManager(new SessionStore(Cache::driver(), 'session-1')); + $manager->setLevel(LogLevel::Info); + + expect($manager->shouldLog(LogLevel::Emergency))->toBeTrue() + ->and($manager->shouldLog(LogLevel::Error))->toBeTrue() + ->and($manager->shouldLog(LogLevel::Info))->toBeTrue() + ->and($manager->shouldLog(LogLevel::Debug))->toBeFalse(); +}); + +test('it uses default level for null session id', function (): void { + $manager = new LoggingManager(new SessionStore(Cache::driver())); + + expect($manager->getLevel())->toBe(LogLevel::Info) + ->and($manager->shouldLog(LogLevel::Info))->toBeTrue() + ->and($manager->shouldLog(LogLevel::Debug))->toBeFalse(); +}); + +test('setLevel ignores null session id', function (): void { + $manager = new LoggingManager(new SessionStore(Cache::driver())); + $manager->setLevel(LogLevel::Debug); + + expect($manager->getLevel())->toBe(LogLevel::Info); +}); diff --git a/tests/Unit/Server/Support/SessionStoreManagerTest.php b/tests/Unit/Server/Support/SessionStoreManagerTest.php new file mode 100644 index 00000000..b615b4ac --- /dev/null +++ b/tests/Unit/Server/Support/SessionStoreManagerTest.php @@ -0,0 +1,82 @@ +set('key', 'value'); + + expect($session->get('key'))->toBe('value'); +}); + +test('it returns the default value when the key does not exist', function (): void { + $session = new SessionStore(Cache::driver(), 'session-1'); + + expect($session->get('nonexistent', 'default'))->toBe('default'); +}); + +test('it maintains separate values for different sessions', function (): void { + $session1 = new SessionStore(Cache::driver(), 'session-1'); + $session2 = new SessionStore(Cache::driver(), 'session-2'); + + $session1->set('key', 'value-1'); + $session2->set('key', 'value-2'); + + expect($session1->get('key'))->toBe('value-1') + ->and($session2->get('key'))->toBe('value-2'); +}); + +test('it can check if a key exists', function (): void { + $session = new SessionStore(Cache::driver(), 'session-1'); + + expect($session->has('key'))->toBeFalse(); + + $session->set('key', 'value'); + + expect($session->has('key'))->toBeTrue(); +}); + +test('it can forget a key', function (): void { + $session = new SessionStore(Cache::driver(), 'session-1'); + + $session->set('key', 'value'); + + expect($session->has('key'))->toBeTrue(); + + $session->forget('key'); + expect($session->has('key'))->toBeFalse(); +}); + +test('it returns session id', function (): void { + $session = new SessionStore(Cache::driver(), 'my-session-id'); + + expect($session->sessionId())->toBe('my-session-id'); +}); + +test('it handles null session id gracefully', function (): void { + $session = new SessionStore(Cache::driver()); + + $session->set('key', 'value'); + $session->forget('key'); + + expect($session->get('key', 'default'))->toBe('default') + ->and($session->has('key'))->toBeFalse(); +}); + +test('it can store complex values', function (): void { + $session = new SessionStore(Cache::driver(), 'session-1'); + + $session->set('array', ['foo' => 'bar', 'baz' => [1, 2, 3]]); + $session->set('object', (object) ['name' => 'test']); + + expect($session->get('array'))->toBe(['foo' => 'bar', 'baz' => [1, 2, 3]]) + ->and($session->get('object'))->toEqual((object) ['name' => 'test']); +}); diff --git a/tests/Unit/ServerTest.php b/tests/Unit/ServerTest.php index 2f6238c6..9dd239c6 100644 --- a/tests/Unit/ServerTest.php +++ b/tests/Unit/ServerTest.php @@ -4,7 +4,7 @@ use Tests\Fixtures\CustomMethodHandler; use Tests\Fixtures\ExampleServer; -it('can handle an initialize message', function (): void { +it('can handle an initialized message', function (): void { $transport = new ArrayTransport; $server = new ExampleServer($transport); @@ -32,18 +32,21 @@ ($transport->handler)($payload); - $jsonResponse = $transport->sent[0]; + $response = json_decode((string) $transport->sent[0], true); - $capabilities = (fn (): array => $this->capabilities)->call($server); + expect($response)->toHaveKey('result.capabilities'); - $expectedCapabilitiesJson = json_encode(array_merge($capabilities, [ - 'customFeature' => [ - 'enabled' => true, - ], - 'anotherFeature' => (object) [], - ])); + $capabilities = $response['result']['capabilities']; + + expect($capabilities)->toHaveKey('customFeature') + ->and($capabilities['customFeature'])->toBeArray() + ->and($capabilities['customFeature']['enabled'])->toBeTrue() + ->and($capabilities)->toHaveKey('anotherFeature') + ->and($capabilities['anotherFeature'])->toBeArray() + ->and($capabilities)->toHaveKey('tools') + ->and($capabilities)->toHaveKey('resources') + ->and($capabilities)->toHaveKey('prompts'); - $this->assertStringContainsString($expectedCapabilitiesJson, $jsonResponse); }); it('can handle a list tools message', function (): void {