From 8637247085536738f506e64ef40c9f5ca3f00568 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Thu, 27 Nov 2025 17:48:13 +0530 Subject: [PATCH 1/8] Add Logging Support --- src/Response.php | 13 ++ src/Server.php | 2 + src/Server/Content/LogNotification.php | 50 +++++ src/Server/Enums/LogLevel.php | 41 ++++ src/Server/LoggingManager.php | 50 +++++ src/Server/McpServiceProvider.php | 20 ++ .../Concerns/InteractsWithResponses.php | 11 + src/Server/Methods/SetLogLevel.php | 50 +++++ src/Server/Store/SessionStoreManager.php | 67 ++++++ src/Server/Testing/TestResponse.php | 78 +++++++ tests/Feature/Logging/LogFilteringTest.php | 109 +++++++++ tests/Feature/Testing/Tools/AssertLogTest.php | 161 +++++++++++++ tests/Unit/Content/LoggingMessageTest.php | 158 +++++++++++++ tests/Unit/Methods/SetLogLevelTest.php | 211 ++++++++++++++++++ tests/Unit/ResponseTest.php | 50 ++++- tests/Unit/Server/Enums/LogLevelTest.php | 52 +++++ tests/Unit/Server/LoggingManagerTest.php | 76 +++++++ .../Server/Store/SessionStoreManagerTest.php | 100 +++++++++ 18 files changed, 1297 insertions(+), 2 deletions(-) create mode 100644 src/Server/Content/LogNotification.php create mode 100644 src/Server/Enums/LogLevel.php create mode 100644 src/Server/LoggingManager.php create mode 100644 src/Server/Methods/SetLogLevel.php create mode 100644 src/Server/Store/SessionStoreManager.php create mode 100644 tests/Feature/Logging/LogFilteringTest.php create mode 100644 tests/Feature/Testing/Tools/AssertLogTest.php create mode 100644 tests/Unit/Content/LoggingMessageTest.php create mode 100644 tests/Unit/Methods/SetLogLevelTest.php create mode 100644 tests/Unit/Server/Enums/LogLevelTest.php create mode 100644 tests/Unit/Server/LoggingManagerTest.php create mode 100644 tests/Unit/Server/Store/SessionStoreManagerTest.php diff --git a/src/Response.php b/src/Response.php index a579c811..e371fa0e 100644 --- a/src/Response.php +++ b/src/Response.php @@ -11,9 +11,11 @@ use Laravel\Mcp\Enums\Role; use Laravel\Mcp\Exceptions\NotImplementedException; use Laravel\Mcp\Server\Content\Blob; +use Laravel\Mcp\Server\Content\LogNotification; use Laravel\Mcp\Server\Content\Notification; use Laravel\Mcp\Server\Content\Text; use Laravel\Mcp\Server\Contracts\Content; +use Laravel\Mcp\Server\Enums\LogLevel; class Response { @@ -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 LogNotification($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 95c91102..ba08955a 100644 --- a/src/Server.php +++ b/src/Server.php @@ -17,6 +17,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; @@ -96,6 +97,7 @@ abstract class Server 'prompts/list' => ListPrompts::class, 'prompts/get' => GetPrompt::class, 'ping' => Ping::class, + 'logging/setLevel' => SetLogLevel::class, ]; public function __construct( diff --git a/src/Server/Content/LogNotification.php b/src/Server/Content/LogNotification.php new file mode 100644 index 00000000..bf1f8bc2 --- /dev/null +++ b/src/Server/Content/LogNotification.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/Enums/LogLevel.php b/src/Server/Enums/LogLevel.php new file mode 100644 index 00000000..9a34e011 --- /dev/null +++ b/src/Server/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/Server/LoggingManager.php b/src/Server/LoggingManager.php new file mode 100644 index 00000000..a8cc933f --- /dev/null +++ b/src/Server/LoggingManager.php @@ -0,0 +1,50 @@ +session->set(self::LOG_LEVEL_KEY, $level); + } + + public function getLevel(): LogLevel + { + if ($this->session->sessionId() === null) { + return self::$defaultLevel; + } + + return $this->session->get(self::LOG_LEVEL_KEY, self::$defaultLevel); + } + + public function shouldLog(LogLevel $messageLevel): bool + { + return $messageLevel->shouldLog($this->getLevel()); + } + + public static function setDefaultLevel(LogLevel $level): void + { + self::$defaultLevel = $level; + } + + public static function getDefaultLevel(): LogLevel + { + return self::$defaultLevel; + } +} diff --git a/src/Server/McpServiceProvider.php b/src/Server/McpServiceProvider.php index 4bbd9af9..d57ab3d9 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; @@ -21,6 +22,25 @@ public function register(): void $this->app->singleton(Registrar::class, fn (): Registrar => new Registrar); $this->mergeConfigFrom(__DIR__.'/../../config/mcp.php', 'mcp'); + + $this->app->bind(Store\SessionStoreManager::class, function ($app): Store\SessionStoreManager { + $sessionId = null; + + if ($app->bound('mcp.request')) { + /** @var Request $request */ + $request = $app->make('mcp.request'); + $sessionId = $request->sessionId(); + } + + return new Store\SessionStoreManager( + $app->make(Repository::class), + $sessionId + ); + }); + + $this->app->bind(LoggingManager::class, fn ($app): LoggingManager => new LoggingManager( + $app->make(Store\SessionStoreManager::class) + )); } public function boot(): void diff --git a/src/Server/Methods/Concerns/InteractsWithResponses.php b/src/Server/Methods/Concerns/InteractsWithResponses.php index 75e8b9fb..95c0690b 100644 --- a/src/Server/Methods/Concerns/InteractsWithResponses.php +++ b/src/Server/Methods/Concerns/InteractsWithResponses.php @@ -5,13 +5,16 @@ namespace Laravel\Mcp\Server\Methods\Concerns; use Generator; +use Illuminate\Container\Container; use Illuminate\Support\Arr; use Illuminate\Validation\ValidationException; use Laravel\Mcp\Response; use Laravel\Mcp\ResponseFactory; +use Laravel\Mcp\Server\Content\LogNotification; use Laravel\Mcp\Server\Content\Notification; use Laravel\Mcp\Server\Contracts\Errable; use Laravel\Mcp\Server\Exceptions\JsonRpcException; +use Laravel\Mcp\Server\LoggingManager; use Laravel\Mcp\Server\Transport\JsonRpcRequest; use Laravel\Mcp\Server\Transport\JsonRpcResponse; @@ -52,6 +55,14 @@ protected function toJsonRpcStreamedResponse(JsonRpcRequest $request, iterable $ /** @var Notification $content */ $content = $response->content(); + if ($content instanceof LogNotification) { + $loggingManager = Container::getInstance()->make(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..d753f084 --- /dev/null +++ b/src/Server/Methods/SetLogLevel.php @@ -0,0 +1,50 @@ +params['level'] ?? null; + + 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) { + throw new JsonRpcException( + "Invalid log level [{$levelString}]. Must be one of: emergency, alert, critical, error, warning, notice, info, debug.", + -32602, + $request->id, + ); + } + + $this->loggingManager->setLevel($level); + + return JsonRpcResponse::result($request->id, []); + } +} diff --git a/src/Server/Store/SessionStoreManager.php b/src/Server/Store/SessionStoreManager.php new file mode 100644 index 00000000..4b6e34ec --- /dev/null +++ b/src/Server/Store/SessionStoreManager.php @@ -0,0 +1,67 @@ +sessionId === null) { + return; + } + + $this->cache->put($this->cacheKey($key), $value, self::TTL); + } + + public function get(string $key, mixed $default = null): mixed + { + if ($this->sessionId === null) { + return $default; + } + + return $this->cache->get($this->cacheKey($key), $default); + } + + public function has(string $key): bool + { + if ($this->sessionId === null) { + return false; + } + + return $this->cache->has($this->cacheKey($key)); + } + + public function forget(string $key): void + { + if ($this->sessionId === null) { + return; + } + + $this->cache->forget($this->cacheKey($key)); + } + + public function sessionId(): ?string + { + return $this->sessionId; + } + + protected function cacheKey(string $key): string + { + return self::PREFIX.":{$this->sessionId}:{$key}"; + } +} diff --git a/src/Server/Testing/TestResponse.php b/src/Server/Testing/TestResponse.php index 8a797224..4e4384f2 100644 --- a/src/Server/Testing/TestResponse.php +++ b/src/Server/Testing/TestResponse.php @@ -8,6 +8,7 @@ use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Support\Traits\Conditionable; use Illuminate\Support\Traits\Macroable; +use Laravel\Mcp\Server\Enums\LogLevel; use Laravel\Mcp\Server\Primitive; use Laravel\Mcp\Server\Prompt; use Laravel\Mcp\Server\Resource; @@ -234,6 +235,83 @@ protected function isAuthenticated(?string $guard = null): bool return Container::getInstance()->make('auth')->guard($guard)->check(); } + public function assertLogSent(LogLevel $level, ?string $contains = null): static + { + foreach ($this->notifications as $notification) { + $content = $notification->toArray(); + + if ($content['method'] !== 'notifications/message') { + continue; + } + + $params = $content['params'] ?? []; + if (! isset($params['level'])) { + continue; + } + + if ($params['level'] !== $level->value) { + continue; + } + + if ($contains !== null) { + $data = $params['data'] ?? ''; + $dataString = is_string($data) ? $data : (string) json_encode($data); + if ($dataString === '') { + continue; + } + + if (! str_contains($dataString, $contains)) { + continue; + } + } + + 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 + { + foreach ($this->notifications as $notification) { + $content = $notification->toArray(); + + if ($content['method'] === 'notifications/message') { + $params = $content['params'] ?? []; + + if (isset($params['level']) && $params['level'] === $level->value) { + $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 = collect($this->notifications)->filter(function ($notification): bool { + $content = $notification->toArray(); + + return $content['method'] === 'notifications/message' && isset($content['params']['level']); + }); + + Assert::assertCount( + $count, + $logNotifications, + "The expected number of log notifications [{$count}] does not match the actual count [{$logNotifications->count()}]." + ); + + return $this; + } + 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..c0421d4b --- /dev/null +++ b/tests/Feature/Logging/LogFilteringTest.php @@ -0,0 +1,109 @@ + 'Connection failed', 'host' => 'localhost', 'port' => 5432], + 'database' + ); + + yield Response::log( + LogLevel::Info, + 'Query executed successfully', + 'database' + ); + + yield Response::text('Database operation complete'); + } +} + +it('sends all log levels with default 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('filters logs based on configured log level - error only', function (): void { + LoggingManager::setDefaultLevel(LogLevel::Error); + + $response = LoggingTestServer::tool(LoggingTestTool::class); + + $response->assertLogCount(2) + ->assertLogSent(LogLevel::Emergency) + ->assertLogSent(LogLevel::Error) + ->assertLogNotSent(LogLevel::Warning) + ->assertLogNotSent(LogLevel::Info) + ->assertLogNotSent(LogLevel::Debug); +}); + +it('filters logs based on the configured log level-debug shows all', function (): void { + LoggingManager::setDefaultLevel(LogLevel::Debug); + + $response = LoggingTestServer::tool(LoggingTestTool::class); + + $response->assertLogCount(5) + ->assertLogSent(LogLevel::Emergency) + ->assertLogSent(LogLevel::Error) + ->assertLogSent(LogLevel::Warning) + ->assertLogSent(LogLevel::Info) + ->assertLogSent(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); + + // Just verify both logs were sent + $response->assertSentNotification('notifications/message') + ->assertLogCount(2); +}); diff --git a/tests/Feature/Testing/Tools/AssertLogTest.php b/tests/Feature/Testing/Tools/AssertLogTest.php new file mode 100644 index 00000000..e5c3b82e --- /dev/null +++ b/tests/Feature/Testing/Tools/AssertLogTest.php @@ -0,0 +1,161 @@ + 'Connection failed', 'host' => 'localhost', 'port' => 5432] + ); + + yield Response::text('Done'); + } +} + +it('asserts log was sent with a specific level', function (): void { + $response = LogAssertServer::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 = LogAssertServer::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 = LogAssertServer::tool(MultiLevelLogTool::class); + + $response->assertLogSent(LogLevel::Debug); +})->throws(AssertionFailedError::class); + +it('fails when asserting log sent with wrong message content', function (): void { + $response = LogAssertServer::tool(MultiLevelLogTool::class); + + $response->assertLogSent(LogLevel::Error, 'Wrong message'); +})->throws(AssertionFailedError::class); + +it('asserts log was not sent', function (): void { + $response = LogAssertServer::tool(MultiLevelLogTool::class); + + $response->assertLogNotSent(LogLevel::Debug) + ->assertLogNotSent(LogLevel::Emergency); +}); + +it('fails when asserting log not sent but it was', function (): void { + $response = LogAssertServer::tool(MultiLevelLogTool::class); + + $response->assertLogNotSent(LogLevel::Error); +})->throws(AssertionFailedError::class); + +it('asserts correct log count', function (): void { + $response = LogAssertServer::tool(MultiLevelLogTool::class); + + $response->assertLogCount(3); +}); + +it('fails when asserting wrong log count', function (): void { + $response = LogAssertServer::tool(MultiLevelLogTool::class); + + $response->assertLogCount(5); +})->throws(ExpectationFailedException::class); + +it('asserts zero log count when no logs sent', function (): void { + $response = LogAssertServer::tool(NoLogTool::class); + + $response->assertLogCount(0); +}); + +it('asserts single log count', function (): void { + $response = LogAssertServer::tool(SingleLogTool::class); + + $response->assertLogCount(1) + ->assertLogSent(LogLevel::Error, 'Single error log'); +}); + +it('asserts log sent with array data containing substring', function (): void { + $response = LogAssertServer::tool(ArrayDataLogTool::class); + + $response->assertLogSent(LogLevel::Error, 'Connection failed') + ->assertLogSent(LogLevel::Error, 'localhost'); +}); + +it('chains multiple log assertions', function (): void { + $response = LogAssertServer::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 = LogAssertServer::tool(MultiLevelLogTool::class); + + $response->assertSee('Done') + ->assertLogCount(3) + ->assertLogSent(LogLevel::Error); +}); diff --git a/tests/Unit/Content/LoggingMessageTest.php b/tests/Unit/Content/LoggingMessageTest.php new file mode 100644 index 00000000..d8168dbc --- /dev/null +++ b/tests/Unit/Content/LoggingMessageTest.php @@ -0,0 +1,158 @@ +level())->toBe(LogLevel::Error) + ->and($message->data())->toBe('Something went wrong') + ->and($message->logger())->toBeNull(); +}); + +it('creates a logging message with optional logger name', function (): void { + $message = new LogNotification(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 correct notification format', function (): void { + $message = new LogNotification(LogLevel::Warning, 'Low disk space'); + + expect($message->toArray())->toEqual([ + 'method' => 'notifications/message', + 'params' => [ + 'level' => 'warning', + 'data' => 'Low disk space', + ], + ]); +}); + +it('includes logger in params when provided', function (): void { + $message = new LogNotification(LogLevel::Debug, 'Query executed', 'sql'); + + expect($message->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 LogNotification(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 LogNotification(LogLevel::Info, $data); + + expect($message->data())->toEqual($data); +}); + +it('casts to string as method name', function (): void { + $message = new LogNotification(LogLevel::Info, 'Test message'); + + expect((string) $message)->toBe('notifications/message'); +}); + +it('may be used in tools', function (): void { + $message = new LogNotification(LogLevel::Info, 'Processing'); + + $payload = $message->toTool(new class extends Tool {}); + + expect($payload)->toEqual([ + 'method' => 'notifications/message', + 'params' => [ + 'level' => 'info', + 'data' => 'Processing', + ], + ]); +}); + +it('may be used in prompts', function (): void { + $message = new LogNotification(LogLevel::Warning, 'Deprecation notice'); + + $payload = $message->toPrompt(new class extends Prompt {}); + + expect($payload)->toEqual([ + 'method' => 'notifications/message', + 'params' => [ + 'level' => 'warning', + 'data' => 'Deprecation notice', + ], + ]); +}); + +it('may be used in resources', function (): void { + $message = new LogNotification(LogLevel::Debug, 'Resource loaded'); + $resource = new class extends Resource + { + protected string $uri = 'file://test.txt'; + + protected string $name = 'test'; + + protected string $title = 'Test File'; + + protected string $mimeType = 'text/plain'; + }; + + $payload = $message->toResource($resource); + + expect($payload)->toEqual([ + 'method' => 'notifications/message', + 'params' => [ + 'level' => 'debug', + 'data' => 'Resource loaded', + ], + ]); +}); + +it('supports _meta via setMeta', function (): void { + $message = new LogNotification(LogLevel::Error, 'Error occurred'); + $message->setMeta(['trace_id' => 'abc123']); + + expect($message->toArray())->toEqual([ + 'method' => 'notifications/message', + 'params' => [ + 'level' => 'error', + 'data' => 'Error occurred', + '_meta' => ['trace_id' => 'abc123'], + ], + ]); +}); + +it('does not include _meta if not set', function (): void { + $message = new LogNotification(LogLevel::Info, 'Test'); + + expect($message->toArray()['params'])->not->toHaveKey('_meta'); +}); + +it('supports all log levels', function (LogLevel $level, string $expected): void { + $message = new LogNotification($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..6c6391ba --- /dev/null +++ b/tests/Unit/Methods/SetLogLevelTest.php @@ -0,0 +1,211 @@ + '2.0', + 'id' => 1, + 'method' => 'logging/setLevel', + 'params' => [ + 'level' => 'debug', + ], + ], 'session-123'); + + $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 SessionStoreManager(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 SessionStoreManager(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: [], + serverName: 'Test Server', + serverVersion: '1.0.0', + instructions: 'Test instructions', + maxPaginationLength: 50, + defaultPaginationLength: 10, + tools: [], + resources: [], + prompts: [], + ); + + $loggingManager = new LoggingManager(new SessionStoreManager(Cache::driver(), 'session-456')); + $method = new SetLogLevel($loggingManager); + $response = $method->handle($request, $context); + + expect($response)->toBeInstanceOf(JsonRpcResponse::class); + + $manager = new LoggingManager(new SessionStoreManager(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 exception for 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: [], + serverName: 'Test Server', + serverVersion: '1.0.0', + instructions: 'Test instructions', + maxPaginationLength: 50, + defaultPaginationLength: 10, + tools: [], + resources: [], + prompts: [], + ); + + $loggingManager = new LoggingManager(new SessionStoreManager(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: [], + serverName: 'Test Server', + serverVersion: '1.0.0', + instructions: 'Test instructions', + maxPaginationLength: 50, + defaultPaginationLength: 10, + tools: [], + resources: [], + prompts: [], + ); + + $loggingManager = new LoggingManager(new SessionStoreManager(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: [], + serverName: 'Test Server', + serverVersion: '1.0.0', + instructions: 'Test instructions', + maxPaginationLength: 50, + defaultPaginationLength: 10, + tools: [], + resources: [], + prompts: [], + ); + + $loggingManager = new LoggingManager(new SessionStoreManager(Cache::driver(), 'session-111')); + $method = new SetLogLevel($loggingManager); + $method->handle($request, $context); +}); diff --git a/tests/Unit/ResponseTest.php b/tests/Unit/ResponseTest.php index 257e3d81..0a35697a 100644 --- a/tests/Unit/ResponseTest.php +++ b/tests/Unit/ResponseTest.php @@ -7,8 +7,10 @@ use Laravel\Mcp\Response; use Laravel\Mcp\ResponseFactory; use Laravel\Mcp\Server\Content\Blob; +use Laravel\Mcp\Server\Content\LogNotification; use Laravel\Mcp\Server\Content\Notification; use Laravel\Mcp\Server\Content\Text; +use Laravel\Mcp\Server\Enums\LogLevel; it('creates a notification response', function (): void { $response = Response::notification('test.method', ['key' => 'value']); @@ -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(LogNotification::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(LogNotification::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(LogNotification::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..683356a0 --- /dev/null +++ b/tests/Unit/Server/Enums/LogLevelTest.php @@ -0,0 +1,52 @@ +severity())->toBe(0); + expect(LogLevel::Alert->severity())->toBe(1); + expect(LogLevel::Critical->severity())->toBe(2); + expect(LogLevel::Error->severity())->toBe(3); + expect(LogLevel::Warning->severity())->toBe(4); + expect(LogLevel::Notice->severity())->toBe(5); + expect(LogLevel::Info->severity())->toBe(6); + expect(LogLevel::Debug->severity())->toBe(7); +}); + +test('it correctly determines if a log should be sent', function (): void { + expect(LogLevel::Emergency->shouldLog(LogLevel::Info))->toBeTrue(); + expect(LogLevel::Error->shouldLog(LogLevel::Info))->toBeTrue(); + expect(LogLevel::Info->shouldLog(LogLevel::Info))->toBeTrue(); + expect(LogLevel::Debug->shouldLog(LogLevel::Info))->toBeFalse(); + + expect(LogLevel::Error->shouldLog(LogLevel::Error))->toBeTrue(); + expect(LogLevel::Warning->shouldLog(LogLevel::Error))->toBeFalse(); + expect(LogLevel::Info->shouldLog(LogLevel::Error))->toBeFalse(); + + expect(LogLevel::Emergency->shouldLog(LogLevel::Debug))->toBeTrue(); + expect(LogLevel::Debug->shouldLog(LogLevel::Debug))->toBeTrue(); + expect(LogLevel::Info->shouldLog(LogLevel::Debug))->toBeTrue(); +}); + +test('it can be created from string', function (): void { + expect(LogLevel::fromString('emergency'))->toBe(LogLevel::Emergency); + expect(LogLevel::fromString('alert'))->toBe(LogLevel::Alert); + expect(LogLevel::fromString('critical'))->toBe(LogLevel::Critical); + expect(LogLevel::fromString('error'))->toBe(LogLevel::Error); + expect(LogLevel::fromString('warning'))->toBe(LogLevel::Warning); + expect(LogLevel::fromString('notice'))->toBe(LogLevel::Notice); + expect(LogLevel::fromString('info'))->toBe(LogLevel::Info); + expect(LogLevel::fromString('debug'))->toBe(LogLevel::Debug); +}); + +test('it handles case insensitive string conversion', function (): void { + expect(LogLevel::fromString('EMERGENCY'))->toBe(LogLevel::Emergency); + expect(LogLevel::fromString('Error'))->toBe(LogLevel::Error); + expect(LogLevel::fromString('INFO'))->toBe(LogLevel::Info); +}); + +test('it throws exception for invalid level string', function (): void { + LogLevel::fromString('invalid'); +})->throws(ValueError::class); diff --git a/tests/Unit/Server/LoggingManagerTest.php b/tests/Unit/Server/LoggingManagerTest.php new file mode 100644 index 00000000..67418e6f --- /dev/null +++ b/tests/Unit/Server/LoggingManagerTest.php @@ -0,0 +1,76 @@ +getLevel())->toBe(LogLevel::Info); +}); + +test('it can set and get log level for a session', function (): void { + $manager = new LoggingManager(new SessionStoreManager(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 SessionStoreManager(Cache::driver(), 'session-1')); + $manager2 = new LoggingManager(new SessionStoreManager(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 SessionStoreManager(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 SessionStoreManager(Cache::driver())); + + expect($manager->getLevel())->toBe(LogLevel::Info) + ->and($manager->shouldLog(LogLevel::Info))->toBeTrue() + ->and($manager->shouldLog(LogLevel::Debug))->toBeFalse(); +}); + +test('it can change the default level', function (): void { + LoggingManager::setDefaultLevel(LogLevel::Warning); + + $manager1 = new LoggingManager(new SessionStoreManager(Cache::driver(), 'new-session')); + $manager2 = new LoggingManager(new SessionStoreManager(Cache::driver())); + + expect($manager1->getLevel())->toBe(LogLevel::Warning) + ->and($manager2->getLevel())->toBe(LogLevel::Warning); +}); + +test('setLevel ignores null session id', function (): void { + $manager = new LoggingManager(new SessionStoreManager(Cache::driver())); + $manager->setLevel(LogLevel::Debug); + + expect($manager->getLevel())->toBe(LogLevel::Info); +}); + +test('it can get default level', function (): void { + LoggingManager::setDefaultLevel(LogLevel::Warning); + + expect(LoggingManager::getDefaultLevel())->toBe(LogLevel::Warning); +}); diff --git a/tests/Unit/Server/Store/SessionStoreManagerTest.php b/tests/Unit/Server/Store/SessionStoreManagerTest.php new file mode 100644 index 00000000..489c6816 --- /dev/null +++ b/tests/Unit/Server/Store/SessionStoreManagerTest.php @@ -0,0 +1,100 @@ +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 SessionStoreManager(Cache::driver(), 'session-1'); + + expect($session->get('nonexistent', 'default'))->toBe('default'); +}); + +test('it maintains separate values for different sessions', function (): void { + $session1 = new SessionStoreManager(Cache::driver(), 'session-1'); + $session2 = new SessionStoreManager(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 SessionStoreManager(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 SessionStoreManager(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 SessionStoreManager(Cache::driver(), 'my-session-id'); + + expect($session->sessionId())->toBe('my-session-id'); +}); + +test('it handles null session id gracefully for set', function (): void { + $session = new SessionStoreManager(Cache::driver()); + + $session->set('key', 'value'); + + expect($session->get('key'))->toBeNull(); +}); + +test('it handles null session id gracefully for get', function (): void { + $session = new SessionStoreManager(Cache::driver()); + + expect($session->get('key', 'default'))->toBe('default'); +}); + +test('it handles null session id gracefully for has', function (): void { + $session = new SessionStoreManager(Cache::driver()); + + expect($session->has('key'))->toBeFalse(); +}); + +test('it handles null session id gracefully for forget', function (): void { + $session = new SessionStoreManager(Cache::driver()); + + $session->forget('key'); + + expect(true)->toBeTrue(); +}); + +test('it can store complex values', function (): void { + $session = new SessionStoreManager(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']); +}); From 955904c6e0bca1ad80b258fbd7ff1b18f4eecdb6 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Thu, 27 Nov 2025 19:07:42 +0530 Subject: [PATCH 2/8] Import `Cache` facade in `LoggingManagerTest`. --- tests/Unit/Server/LoggingManagerTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Unit/Server/LoggingManagerTest.php b/tests/Unit/Server/LoggingManagerTest.php index 67418e6f..ee34b499 100644 --- a/tests/Unit/Server/LoggingManagerTest.php +++ b/tests/Unit/Server/LoggingManagerTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Illuminate\Support\Facades\Cache; use Laravel\Mcp\Server\Enums\LogLevel; use Laravel\Mcp\Server\LoggingManager; use Laravel\Mcp\Server\Store\SessionStoreManager; From 59505b4d373fcd329621b3cdb0bfe74412ea2175 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Fri, 28 Nov 2025 14:35:23 +0530 Subject: [PATCH 3/8] Change Namespace --- config/mcp.php | 13 ++++++ src/{Server => }/Enums/LogLevel.php | 2 +- src/Response.php | 2 +- src/Server.php | 1 + src/Server/Content/LogNotification.php | 2 +- src/Server/McpServiceProvider.php | 43 +++++++++++-------- .../Concerns/InteractsWithResponses.php | 12 ++---- src/Server/Methods/SetLogLevel.php | 4 +- src/Server/{ => Support}/LoggingManager.php | 5 +-- .../SessionStoreManager.php | 9 ++-- src/Server/Testing/TestResponse.php | 2 +- tests/Feature/Logging/LogFilteringTest.php | 25 ++++++++++- tests/Feature/Testing/Tools/AssertLogTest.php | 4 +- tests/Unit/Content/LoggingMessageTest.php | 2 +- tests/Unit/Methods/SetLogLevelTest.php | 6 +-- tests/Unit/ResponseTest.php | 2 +- tests/Unit/Server/Enums/LogLevelTest.php | 2 +- .../{ => Support}/LoggingManagerTest.php | 6 +-- .../SessionStoreManagerTest.php | 2 +- 19 files changed, 90 insertions(+), 54 deletions(-) rename src/{Server => }/Enums/LogLevel.php (96%) rename src/Server/{ => Support}/LoggingManager.php (89%) rename src/Server/{Store => Support}/SessionStoreManager.php (85%) rename tests/Unit/Server/{ => Support}/LoggingManagerTest.php (95%) rename tests/Unit/Server/{Store => Support}/SessionStoreManagerTest.php (98%) diff --git a/config/mcp.php b/config/mcp.php index 5165ce1a..3770be75 100644 --- a/config/mcp.php +++ b/config/mcp.php @@ -20,4 +20,17 @@ // 'https://example.com', ], + /* + |-------------------------------------------------------------------------- + | Session 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/Server/Enums/LogLevel.php b/src/Enums/LogLevel.php similarity index 96% rename from src/Server/Enums/LogLevel.php rename to src/Enums/LogLevel.php index 9a34e011..14a776fd 100644 --- a/src/Server/Enums/LogLevel.php +++ b/src/Enums/LogLevel.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Laravel\Mcp\Server\Enums; +namespace Laravel\Mcp\Enums; enum LogLevel: string { diff --git a/src/Response.php b/src/Response.php index e371fa0e..9936ebf1 100644 --- a/src/Response.php +++ b/src/Response.php @@ -8,6 +8,7 @@ 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; @@ -15,7 +16,6 @@ use Laravel\Mcp\Server\Content\Notification; use Laravel\Mcp\Server\Content\Text; use Laravel\Mcp\Server\Contracts\Content; -use Laravel\Mcp\Server\Enums\LogLevel; class Response { diff --git a/src/Server.php b/src/Server.php index ba08955a..b0296c8e 100644 --- a/src/Server.php +++ b/src/Server.php @@ -65,6 +65,7 @@ abstract class Server 'prompts' => [ 'listChanged' => false, ], + 'logging' => [], ]; /** diff --git a/src/Server/Content/LogNotification.php b/src/Server/Content/LogNotification.php index bf1f8bc2..2361f869 100644 --- a/src/Server/Content/LogNotification.php +++ b/src/Server/Content/LogNotification.php @@ -4,7 +4,7 @@ namespace Laravel\Mcp\Server\Content; -use Laravel\Mcp\Server\Enums\LogLevel; +use Laravel\Mcp\Enums\LogLevel; class LogNotification extends Notification { diff --git a/src/Server/McpServiceProvider.php b/src/Server/McpServiceProvider.php index d57ab3d9..16d1d2d1 100644 --- a/src/Server/McpServiceProvider.php +++ b/src/Server/McpServiceProvider.php @@ -14,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\SessionStoreManager; class McpServiceProvider extends ServiceProvider { @@ -23,24 +25,7 @@ public function register(): void $this->mergeConfigFrom(__DIR__.'/../../config/mcp.php', 'mcp'); - $this->app->bind(Store\SessionStoreManager::class, function ($app): Store\SessionStoreManager { - $sessionId = null; - - if ($app->bound('mcp.request')) { - /** @var Request $request */ - $request = $app->make('mcp.request'); - $sessionId = $request->sessionId(); - } - - return new Store\SessionStoreManager( - $app->make(Repository::class), - $sessionId - ); - }); - - $this->app->bind(LoggingManager::class, fn ($app): LoggingManager => new LoggingManager( - $app->make(Store\SessionStoreManager::class) - )); + $this->registerSessionBindings(); } public function boot(): void @@ -105,6 +90,28 @@ protected function registerContainerCallbacks(): void }); } + protected function registerSessionBindings(): void + { + $this->app->bind(SessionStoreManager::class, function ($app): Support\SessionStoreManager { + $sessionId = null; + + if ($app->bound('mcp.request')) { + /** @var Request $request */ + $request = $app->make('mcp.request'); + $sessionId = $request->sessionId(); + } + + return new SessionStoreManager( + $app->make(Repository::class), + $sessionId + ); + }); + + $this->app->bind(LoggingManager::class, fn ($app): LoggingManager => new LoggingManager( + $app->make(Support\SessionStoreManager::class) + )); + } + protected function registerCommands(): void { $this->commands([ diff --git a/src/Server/Methods/Concerns/InteractsWithResponses.php b/src/Server/Methods/Concerns/InteractsWithResponses.php index 95c0690b..d7aad37a 100644 --- a/src/Server/Methods/Concerns/InteractsWithResponses.php +++ b/src/Server/Methods/Concerns/InteractsWithResponses.php @@ -5,7 +5,6 @@ namespace Laravel\Mcp\Server\Methods\Concerns; use Generator; -use Illuminate\Container\Container; use Illuminate\Support\Arr; use Illuminate\Validation\ValidationException; use Laravel\Mcp\Response; @@ -14,7 +13,7 @@ use Laravel\Mcp\Server\Content\Notification; use Laravel\Mcp\Server\Contracts\Errable; use Laravel\Mcp\Server\Exceptions\JsonRpcException; -use Laravel\Mcp\Server\LoggingManager; +use Laravel\Mcp\Server\Support\LoggingManager; use Laravel\Mcp\Server\Transport\JsonRpcRequest; use Laravel\Mcp\Server\Transport\JsonRpcResponse; @@ -48,6 +47,7 @@ protected function toJsonRpcStreamedResponse(JsonRpcRequest $request, iterable $ { /** @var array $pendingResponses */ $pendingResponses = []; + $loggingManager = app(LoggingManager::class); try { foreach ($responses as $response) { @@ -55,12 +55,8 @@ protected function toJsonRpcStreamedResponse(JsonRpcRequest $request, iterable $ /** @var Notification $content */ $content = $response->content(); - if ($content instanceof LogNotification) { - $loggingManager = Container::getInstance()->make(LoggingManager::class); - - if (! $loggingManager->shouldLog($content->level())) { - continue; - } + if ($content instanceof LogNotification && ! $loggingManager->shouldLog($content->level())) { + continue; } yield JsonRpcResponse::notification( diff --git a/src/Server/Methods/SetLogLevel.php b/src/Server/Methods/SetLogLevel.php index d753f084..2e11fe3e 100644 --- a/src/Server/Methods/SetLogLevel.php +++ b/src/Server/Methods/SetLogLevel.php @@ -4,11 +4,11 @@ namespace Laravel\Mcp\Server\Methods; +use Laravel\Mcp\Enums\LogLevel; use Laravel\Mcp\Server\Contracts\Method; -use Laravel\Mcp\Server\Enums\LogLevel; use Laravel\Mcp\Server\Exceptions\JsonRpcException; -use Laravel\Mcp\Server\LoggingManager; use Laravel\Mcp\Server\ServerContext; +use Laravel\Mcp\Server\Support\LoggingManager; use Laravel\Mcp\Server\Transport\JsonRpcRequest; use Laravel\Mcp\Server\Transport\JsonRpcResponse; use ValueError; diff --git a/src/Server/LoggingManager.php b/src/Server/Support/LoggingManager.php similarity index 89% rename from src/Server/LoggingManager.php rename to src/Server/Support/LoggingManager.php index a8cc933f..5b105d89 100644 --- a/src/Server/LoggingManager.php +++ b/src/Server/Support/LoggingManager.php @@ -2,10 +2,9 @@ declare(strict_types=1); -namespace Laravel\Mcp\Server; +namespace Laravel\Mcp\Server\Support; -use Laravel\Mcp\Server\Enums\LogLevel; -use Laravel\Mcp\Server\Store\SessionStoreManager; +use Laravel\Mcp\Enums\LogLevel; class LoggingManager { diff --git a/src/Server/Store/SessionStoreManager.php b/src/Server/Support/SessionStoreManager.php similarity index 85% rename from src/Server/Store/SessionStoreManager.php rename to src/Server/Support/SessionStoreManager.php index 4b6e34ec..e03378d4 100644 --- a/src/Server/Store/SessionStoreManager.php +++ b/src/Server/Support/SessionStoreManager.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Laravel\Mcp\Server\Store; +namespace Laravel\Mcp\Server\Support; use Illuminate\Contracts\Cache\Repository as Cache; @@ -10,13 +10,12 @@ class SessionStoreManager { protected const PREFIX = 'mcp'; - protected const TTL = 3600; - public function __construct( protected Cache $cache, protected ?string $sessionId = null, + protected ?int $ttl = null, ) { - // + $this->ttl ??= config('mcp.session_ttl', 86400); } public function set(string $key, mixed $value): void @@ -25,7 +24,7 @@ public function set(string $key, mixed $value): void return; } - $this->cache->put($this->cacheKey($key), $value, self::TTL); + $this->cache->put($this->cacheKey($key), $value, $this->ttl); } public function get(string $key, mixed $default = null): mixed diff --git a/src/Server/Testing/TestResponse.php b/src/Server/Testing/TestResponse.php index 4e4384f2..b1c81fda 100644 --- a/src/Server/Testing/TestResponse.php +++ b/src/Server/Testing/TestResponse.php @@ -8,7 +8,7 @@ use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Support\Traits\Conditionable; use Illuminate\Support\Traits\Macroable; -use Laravel\Mcp\Server\Enums\LogLevel; +use Laravel\Mcp\Enums\LogLevel; use Laravel\Mcp\Server\Primitive; use Laravel\Mcp\Server\Prompt; use Laravel\Mcp\Server\Resource; diff --git a/tests/Feature/Logging/LogFilteringTest.php b/tests/Feature/Logging/LogFilteringTest.php index c0421d4b..d912158e 100644 --- a/tests/Feature/Logging/LogFilteringTest.php +++ b/tests/Feature/Logging/LogFilteringTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); +use Laravel\Mcp\Enums\LogLevel; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server; -use Laravel\Mcp\Server\Enums\LogLevel; -use Laravel\Mcp\Server\LoggingManager; +use Laravel\Mcp\Server\Support\LoggingManager; use Laravel\Mcp\Server\Tool; beforeEach(function (): void { @@ -107,3 +107,24 @@ public function handle(Request $request): \Generator $response->assertSentNotification('notifications/message') ->assertLogCount(2); }); + +it('resolves logging manager from container correctly during streaming', function (): void { + LoggingManager::setDefaultLevel(LogLevel::Warning); + + $response = LoggingTestServer::tool(LoggingTestTool::class); + + $response->assertLogCount(3) + ->assertLogSent(LogLevel::Emergency) + ->assertLogSent(LogLevel::Error) + ->assertLogSent(LogLevel::Warning) + ->assertLogNotSent(LogLevel::Info) + ->assertLogNotSent(LogLevel::Debug); +}); + +it('handles multiple log notifications efficiently', function (): void { + LoggingManager::setDefaultLevel(LogLevel::Debug); + + $response = LoggingTestServer::tool(LoggingTestTool::class); + + $response->assertLogCount(5); +}); diff --git a/tests/Feature/Testing/Tools/AssertLogTest.php b/tests/Feature/Testing/Tools/AssertLogTest.php index e5c3b82e..d9ca2361 100644 --- a/tests/Feature/Testing/Tools/AssertLogTest.php +++ b/tests/Feature/Testing/Tools/AssertLogTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); +use Laravel\Mcp\Enums\LogLevel; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server; -use Laravel\Mcp\Server\Enums\LogLevel; -use Laravel\Mcp\Server\LoggingManager; +use Laravel\Mcp\Server\Support\LoggingManager; use Laravel\Mcp\Server\Tool; use PHPUnit\Framework\AssertionFailedError; use PHPUnit\Framework\ExpectationFailedException; diff --git a/tests/Unit/Content/LoggingMessageTest.php b/tests/Unit/Content/LoggingMessageTest.php index d8168dbc..ce4a3e58 100644 --- a/tests/Unit/Content/LoggingMessageTest.php +++ b/tests/Unit/Content/LoggingMessageTest.php @@ -2,8 +2,8 @@ declare(strict_types=1); +use Laravel\Mcp\Enums\LogLevel; use Laravel\Mcp\Server\Content\LogNotification; -use Laravel\Mcp\Server\Enums\LogLevel; use Laravel\Mcp\Server\Prompt; use Laravel\Mcp\Server\Resource; use Laravel\Mcp\Server\Tool; diff --git a/tests/Unit/Methods/SetLogLevelTest.php b/tests/Unit/Methods/SetLogLevelTest.php index 6c6391ba..c1fc3b4d 100644 --- a/tests/Unit/Methods/SetLogLevelTest.php +++ b/tests/Unit/Methods/SetLogLevelTest.php @@ -3,12 +3,12 @@ declare(strict_types=1); use Illuminate\Support\Facades\Cache; -use Laravel\Mcp\Server\Enums\LogLevel; +use Laravel\Mcp\Enums\LogLevel; use Laravel\Mcp\Server\Exceptions\JsonRpcException; -use Laravel\Mcp\Server\LoggingManager; use Laravel\Mcp\Server\Methods\SetLogLevel; use Laravel\Mcp\Server\ServerContext; -use Laravel\Mcp\Server\Store\SessionStoreManager; +use Laravel\Mcp\Server\Support\LoggingManager; +use Laravel\Mcp\Server\Support\SessionStoreManager; use Laravel\Mcp\Server\Transport\JsonRpcRequest; use Laravel\Mcp\Server\Transport\JsonRpcResponse; diff --git a/tests/Unit/ResponseTest.php b/tests/Unit/ResponseTest.php index 0a35697a..6e530a98 100644 --- a/tests/Unit/ResponseTest.php +++ b/tests/Unit/ResponseTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Laravel\Mcp\Enums\LogLevel; use Laravel\Mcp\Enums\Role; use Laravel\Mcp\Exceptions\NotImplementedException; use Laravel\Mcp\Response; @@ -10,7 +11,6 @@ use Laravel\Mcp\Server\Content\LogNotification; use Laravel\Mcp\Server\Content\Notification; use Laravel\Mcp\Server\Content\Text; -use Laravel\Mcp\Server\Enums\LogLevel; it('creates a notification response', function (): void { $response = Response::notification('test.method', ['key' => 'value']); diff --git a/tests/Unit/Server/Enums/LogLevelTest.php b/tests/Unit/Server/Enums/LogLevelTest.php index 683356a0..c3c5e8bf 100644 --- a/tests/Unit/Server/Enums/LogLevelTest.php +++ b/tests/Unit/Server/Enums/LogLevelTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -use Laravel\Mcp\Server\Enums\LogLevel; +use Laravel\Mcp\Enums\LogLevel; test('it has correct severity values', function (): void { expect(LogLevel::Emergency->severity())->toBe(0); diff --git a/tests/Unit/Server/LoggingManagerTest.php b/tests/Unit/Server/Support/LoggingManagerTest.php similarity index 95% rename from tests/Unit/Server/LoggingManagerTest.php rename to tests/Unit/Server/Support/LoggingManagerTest.php index ee34b499..91fe558f 100644 --- a/tests/Unit/Server/LoggingManagerTest.php +++ b/tests/Unit/Server/Support/LoggingManagerTest.php @@ -3,9 +3,9 @@ declare(strict_types=1); use Illuminate\Support\Facades\Cache; -use Laravel\Mcp\Server\Enums\LogLevel; -use Laravel\Mcp\Server\LoggingManager; -use Laravel\Mcp\Server\Store\SessionStoreManager; +use Laravel\Mcp\Enums\LogLevel; +use Laravel\Mcp\Server\Support\LoggingManager; +use Laravel\Mcp\Server\Support\SessionStoreManager; beforeEach(function (): void { LoggingManager::setDefaultLevel(LogLevel::Info); diff --git a/tests/Unit/Server/Store/SessionStoreManagerTest.php b/tests/Unit/Server/Support/SessionStoreManagerTest.php similarity index 98% rename from tests/Unit/Server/Store/SessionStoreManagerTest.php rename to tests/Unit/Server/Support/SessionStoreManagerTest.php index 489c6816..fb8be873 100644 --- a/tests/Unit/Server/Store/SessionStoreManagerTest.php +++ b/tests/Unit/Server/Support/SessionStoreManagerTest.php @@ -3,7 +3,7 @@ declare(strict_types=1); use Illuminate\Support\Facades\Cache; -use Laravel\Mcp\Server\Store\SessionStoreManager; +use Laravel\Mcp\Server\Support\SessionStoreManager; beforeEach(function (): void { Cache::flush(); From ef08de4df4e0b478358a9444d92d226d2d3a0a0d Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Mon, 1 Dec 2025 18:49:02 +0530 Subject: [PATCH 4/8] Fix Issue --- src/Server.php | 37 ++--- src/Server/McpServiceProvider.php | 2 +- .../Concerns/InteractsWithResponses.php | 10 +- .../Logging/ContainerResolutionTest.php | 146 ++++++++++++++++++ tests/Feature/Logging/SetLogLevelTest.php | 74 +++++++++ tests/Unit/ServerTest.php | 21 +-- 6 files changed, 257 insertions(+), 33 deletions(-) create mode 100644 tests/Feature/Logging/ContainerResolutionTest.php create mode 100644 tests/Feature/Logging/SetLogLevelTest.php diff --git a/src/Server.php b/src/Server.php index 0aed23e4..fa3a8931 100644 --- a/src/Server.php +++ b/src/Server.php @@ -66,7 +66,6 @@ abstract class Server 'prompts' => [ 'listChanged' => false, ], - 'logging' => [], ]; /** @@ -233,19 +232,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'); + } } /** @@ -257,20 +260,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/McpServiceProvider.php b/src/Server/McpServiceProvider.php index 16d1d2d1..bcb23d6a 100644 --- a/src/Server/McpServiceProvider.php +++ b/src/Server/McpServiceProvider.php @@ -108,7 +108,7 @@ protected function registerSessionBindings(): void }); $this->app->bind(LoggingManager::class, fn ($app): LoggingManager => new LoggingManager( - $app->make(Support\SessionStoreManager::class) + $app->make(SessionStoreManager::class) )); } diff --git a/src/Server/Methods/Concerns/InteractsWithResponses.php b/src/Server/Methods/Concerns/InteractsWithResponses.php index d7aad37a..84864ca4 100644 --- a/src/Server/Methods/Concerns/InteractsWithResponses.php +++ b/src/Server/Methods/Concerns/InteractsWithResponses.php @@ -47,7 +47,7 @@ protected function toJsonRpcStreamedResponse(JsonRpcRequest $request, iterable $ { /** @var array $pendingResponses */ $pendingResponses = []; - $loggingManager = app(LoggingManager::class); + $loggingManager = null; try { foreach ($responses as $response) { @@ -55,8 +55,12 @@ protected function toJsonRpcStreamedResponse(JsonRpcRequest $request, iterable $ /** @var Notification $content */ $content = $response->content(); - if ($content instanceof LogNotification && ! $loggingManager->shouldLog($content->level())) { - continue; + if ($content instanceof LogNotification) { + $loggingManager ??= app(LoggingManager::class); + + if (! $loggingManager->shouldLog($content->level())) { + continue; + } } yield JsonRpcResponse::notification( diff --git a/tests/Feature/Logging/ContainerResolutionTest.php b/tests/Feature/Logging/ContainerResolutionTest.php new file mode 100644 index 00000000..e3a036fb --- /dev/null +++ b/tests/Feature/Logging/ContainerResolutionTest.php @@ -0,0 +1,146 @@ + $errno, + 'message' => $errstr, + 'file' => $errfile, + 'line' => $errline, + ]; + + return true; // Don't execute PHP's internal error handler + }); + + try { + // First, bind LoggingManager in the container (simulating what we want to test) + app()->bind(LoggingManager::class, fn ($app): LoggingManager => new LoggingManager( + $app->make(SessionStoreManager::class) + )); + + // Now resolve it + $manager = app(LoggingManager::class); + + expect($manager)->toBeInstanceOf(LoggingManager::class); + } finally { + restore_error_handler(); + } + + $output = ob_get_clean(); + + // If there was any output, fail with diagnostic info + if ($output !== '' && $output !== false) { + $hexDump = bin2hex(substr($output, 0, 200)); + throw new \RuntimeException(sprintf( + "Unexpected output during LoggingManager resolution!\nLength: %d bytes\nContent: %s\nHex: %s", + strlen($output), + substr($output, 0, 500), + $hexDump + )); + } + + // If there were any errors/warnings, fail with diagnostic info + if ($errors !== []) { + throw new \RuntimeException(sprintf( + "Errors/warnings during LoggingManager resolution:\n%s", + json_encode($errors, JSON_PRETTY_PRINT) + )); + } + + expect($output)->toBe(''); + expect($errors)->toBe([]); +}); + +it('resolves SessionStoreManager from container without producing output', function (): void { + ob_start(); + + $errors = []; + set_error_handler(function (int $errno, string $errstr, string $errfile, int $errline) use (&$errors): bool { + $errors[] = [ + 'type' => $errno, + 'message' => $errstr, + 'file' => $errfile, + 'line' => $errline, + ]; + + return true; + }); + + try { + $manager = app(SessionStoreManager::class); + expect($manager)->toBeInstanceOf(SessionStoreManager::class); + } finally { + restore_error_handler(); + } + + $output = ob_get_clean(); + + if ($output !== '' && $output !== false) { + throw new \RuntimeException(sprintf( + "Unexpected output during SessionStoreManager resolution!\nLength: %d bytes\nContent: %s", + strlen($output), + substr($output, 0, 500) + )); + } + + if ($errors !== []) { + throw new \RuntimeException(sprintf( + "Errors/warnings during SessionStoreManager resolution:\n%s", + json_encode($errors, JSON_PRETTY_PRINT) + )); + } + + expect($output)->toBe(''); +}); + +it('resolves Cache repository without producing output', function (): void { + ob_start(); + + $errors = []; + set_error_handler(function (int $errno, string $errstr, string $errfile, int $errline) use (&$errors): bool { + $errors[] = [ + 'type' => $errno, + 'message' => $errstr, + 'file' => $errfile, + 'line' => $errline, + ]; + + return true; + }); + + try { + $cache = app(\Illuminate\Contracts\Cache\Repository::class); + expect($cache)->toBeInstanceOf(\Illuminate\Contracts\Cache\Repository::class); + } finally { + restore_error_handler(); + } + + $output = ob_get_clean(); + + if ($output !== '' && $output !== false) { + throw new \RuntimeException(sprintf( + "Unexpected output during Cache resolution!\nLength: %d bytes\nContent: %s", + strlen($output), + substr($output, 0, 500) + )); + } + + if ($errors !== []) { + throw new \RuntimeException(sprintf( + "Errors/warnings during Cache resolution:\n%s", + json_encode($errors, JSON_PRETTY_PRINT) + )); + } + + expect($output)->toBe(''); +}); diff --git a/tests/Feature/Logging/SetLogLevelTest.php b/tests/Feature/Logging/SetLogLevelTest.php new file mode 100644 index 00000000..a245d8c4 --- /dev/null +++ b/tests/Feature/Logging/SetLogLevelTest.php @@ -0,0 +1,74 @@ +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 SessionStoreManager(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 SessionStoreManager(Cache::driver(), $sessionId1)); + $manager2 = new LoggingManager(new SessionStoreManager(Cache::driver(), $sessionId2)); + + expect($manager1->getLevel())->toBe(LogLevel::Debug) + ->and($manager2->getLevel())->toBe(LogLevel::Error); +}); diff --git a/tests/Unit/ServerTest.php b/tests/Unit/ServerTest.php index 2f6238c6..628ca36c 100644 --- a/tests/Unit/ServerTest.php +++ b/tests/Unit/ServerTest.php @@ -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 { From 1966e314baebab0b441a8707829e817c79987232 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Mon, 1 Dec 2025 23:28:56 +0530 Subject: [PATCH 5/8] Clean up --- config/mcp.php | 2 +- src/Response.php | 4 +- .../Content/{LogNotification.php => Log.php} | 2 +- .../Concerns/InteractsWithResponses.php | 4 +- src/Server/Methods/SetLogLevel.php | 7 +- src/Server/Support/LoggingManager.php | 23 +-- src/Server/Support/SessionStoreManager.php | 12 +- src/Server/Testing/TestResponse.php | 82 +++++----- .../Logging/ContainerResolutionTest.php | 146 ------------------ tests/Feature/Logging/LogFilteringTest.php | 140 +++++++++++------ tests/Feature/Testing/Tools/AssertLogTest.php | 11 +- tests/Unit/Content/LoggingMessageTest.php | 94 ++++------- tests/Unit/ResponseTest.php | 8 +- .../Server/Support/LoggingManagerTest.php | 20 --- .../Support/SessionStoreManagerTest.php | 24 +-- 15 files changed, 194 insertions(+), 385 deletions(-) rename src/Server/Content/{LogNotification.php => Log.php} (95%) delete mode 100644 tests/Feature/Logging/ContainerResolutionTest.php diff --git a/config/mcp.php b/config/mcp.php index 3770be75..b3e61423 100644 --- a/config/mcp.php +++ b/config/mcp.php @@ -22,7 +22,7 @@ /* |-------------------------------------------------------------------------- - | Session TTL + | Session Time To Live (TTL) |-------------------------------------------------------------------------- | | This value determines how long (in seconds) MCP session data will be diff --git a/src/Response.php b/src/Response.php index 9936ebf1..7dd281aa 100644 --- a/src/Response.php +++ b/src/Response.php @@ -12,7 +12,7 @@ use Laravel\Mcp\Enums\Role; use Laravel\Mcp\Exceptions\NotImplementedException; use Laravel\Mcp\Server\Content\Blob; -use Laravel\Mcp\Server\Content\LogNotification; +use Laravel\Mcp\Server\Content\Log; use Laravel\Mcp\Server\Content\Notification; use Laravel\Mcp\Server\Content\Text; use Laravel\Mcp\Server\Contracts\Content; @@ -46,7 +46,7 @@ public static function log(LogLevel $level, mixed $data, ?string $logger = null) throw new InvalidArgumentException("Invalid log data: {$jsonException->getMessage()}", 0, $jsonException); } - return new static(new LogNotification($level, $data, $logger)); + return new static(new Log($level, $data, $logger)); } public static function text(string $text): static diff --git a/src/Server/Content/LogNotification.php b/src/Server/Content/Log.php similarity index 95% rename from src/Server/Content/LogNotification.php rename to src/Server/Content/Log.php index 2361f869..f53bc27b 100644 --- a/src/Server/Content/LogNotification.php +++ b/src/Server/Content/Log.php @@ -6,7 +6,7 @@ use Laravel\Mcp\Enums\LogLevel; -class LogNotification extends Notification +class Log extends Notification { public function __construct( protected LogLevel $level, diff --git a/src/Server/Methods/Concerns/InteractsWithResponses.php b/src/Server/Methods/Concerns/InteractsWithResponses.php index 84864ca4..2c603918 100644 --- a/src/Server/Methods/Concerns/InteractsWithResponses.php +++ b/src/Server/Methods/Concerns/InteractsWithResponses.php @@ -9,7 +9,7 @@ use Illuminate\Validation\ValidationException; use Laravel\Mcp\Response; use Laravel\Mcp\ResponseFactory; -use Laravel\Mcp\Server\Content\LogNotification; +use Laravel\Mcp\Server\Content\Log; use Laravel\Mcp\Server\Content\Notification; use Laravel\Mcp\Server\Contracts\Errable; use Laravel\Mcp\Server\Exceptions\JsonRpcException; @@ -55,7 +55,7 @@ protected function toJsonRpcStreamedResponse(JsonRpcRequest $request, iterable $ /** @var Notification $content */ $content = $response->content(); - if ($content instanceof LogNotification) { + if ($content instanceof Log) { $loggingManager ??= app(LoggingManager::class); if (! $loggingManager->shouldLog($content->level())) { diff --git a/src/Server/Methods/SetLogLevel.php b/src/Server/Methods/SetLogLevel.php index 2e11fe3e..ba1309ef 100644 --- a/src/Server/Methods/SetLogLevel.php +++ b/src/Server/Methods/SetLogLevel.php @@ -15,15 +15,14 @@ class SetLogLevel implements Method { - public function __construct( - protected LoggingManager $loggingManager, - ) { + public function __construct(protected LoggingManager $loggingManager) + { // } public function handle(JsonRpcRequest $request, ServerContext $context): JsonRpcResponse { - $levelString = $request->params['level'] ?? null; + $levelString = $request->get('level'); if (! is_string($levelString)) { throw new JsonRpcException( diff --git a/src/Server/Support/LoggingManager.php b/src/Server/Support/LoggingManager.php index 5b105d89..b75ef30c 100644 --- a/src/Server/Support/LoggingManager.php +++ b/src/Server/Support/LoggingManager.php @@ -10,11 +10,10 @@ class LoggingManager { protected const LOG_LEVEL_KEY = 'log_level'; - protected static LogLevel $defaultLevel = LogLevel::Info; + private const DEFAULT_LEVEL = LogLevel::Info; - public function __construct( - protected SessionStoreManager $session, - ) { + public function __construct(protected SessionStoreManager $session) + { // } @@ -25,25 +24,15 @@ public function setLevel(LogLevel $level): void public function getLevel(): LogLevel { - if ($this->session->sessionId() === null) { - return self::$defaultLevel; + if (is_null($this->session->sessionId())) { + return self::DEFAULT_LEVEL; } - return $this->session->get(self::LOG_LEVEL_KEY, self::$defaultLevel); + return $this->session->get(self::LOG_LEVEL_KEY, self::DEFAULT_LEVEL); } public function shouldLog(LogLevel $messageLevel): bool { return $messageLevel->shouldLog($this->getLevel()); } - - public static function setDefaultLevel(LogLevel $level): void - { - self::$defaultLevel = $level; - } - - public static function getDefaultLevel(): LogLevel - { - return self::$defaultLevel; - } } diff --git a/src/Server/Support/SessionStoreManager.php b/src/Server/Support/SessionStoreManager.php index e03378d4..cf1f99d9 100644 --- a/src/Server/Support/SessionStoreManager.php +++ b/src/Server/Support/SessionStoreManager.php @@ -8,7 +8,7 @@ class SessionStoreManager { - protected const PREFIX = 'mcp'; + protected const CACHE_PREFIX = 'mcp'; public function __construct( protected Cache $cache, @@ -20,7 +20,7 @@ public function __construct( public function set(string $key, mixed $value): void { - if ($this->sessionId === null) { + if (is_null($this->sessionId)) { return; } @@ -29,7 +29,7 @@ public function set(string $key, mixed $value): void public function get(string $key, mixed $default = null): mixed { - if ($this->sessionId === null) { + if (is_null($this->sessionId)) { return $default; } @@ -38,7 +38,7 @@ public function get(string $key, mixed $default = null): mixed public function has(string $key): bool { - if ($this->sessionId === null) { + if (is_null($this->sessionId)) { return false; } @@ -47,7 +47,7 @@ public function has(string $key): bool public function forget(string $key): void { - if ($this->sessionId === null) { + if (is_null($this->sessionId)) { return; } @@ -61,6 +61,6 @@ public function sessionId(): ?string protected function cacheKey(string $key): string { - return self::PREFIX.":{$this->sessionId}:{$key}"; + return self::CACHE_PREFIX.":{$this->sessionId}:{$key}"; } } diff --git a/src/Server/Testing/TestResponse.php b/src/Server/Testing/TestResponse.php index b1c81fda..ff614922 100644 --- a/src/Server/Testing/TestResponse.php +++ b/src/Server/Testing/TestResponse.php @@ -6,6 +6,7 @@ use Illuminate\Container\Container; use Illuminate\Contracts\Auth\Authenticatable; +use Illuminate\Support\Collection; use Illuminate\Support\Traits\Conditionable; use Illuminate\Support\Traits\Macroable; use Laravel\Mcp\Enums\LogLevel; @@ -237,34 +238,7 @@ protected function isAuthenticated(?string $guard = null): bool public function assertLogSent(LogLevel $level, ?string $contains = null): static { - foreach ($this->notifications as $notification) { - $content = $notification->toArray(); - - if ($content['method'] !== 'notifications/message') { - continue; - } - - $params = $content['params'] ?? []; - if (! isset($params['level'])) { - continue; - } - - if ($params['level'] !== $level->value) { - continue; - } - - if ($contains !== null) { - $data = $params['data'] ?? ''; - $dataString = is_string($data) ? $data : (string) json_encode($data); - if ($dataString === '') { - continue; - } - - if (! str_contains($dataString, $contains)) { - continue; - } - } - + if ($this->findLogNotification($level, $contains)) { Assert::assertTrue(true); // @phpstan-ignore-line return $this; @@ -277,17 +251,9 @@ public function assertLogSent(LogLevel $level, ?string $contains = null): static public function assertLogNotSent(LogLevel $level): static { - foreach ($this->notifications as $notification) { - $content = $notification->toArray(); - - if ($content['method'] === 'notifications/message') { - $params = $content['params'] ?? []; - - if (isset($params['level']) && $params['level'] === $level->value) { - $levelName = $level->value; - Assert::fail("The log notification with level [{$levelName}] was unexpectedly found."); - } - } + if ($this->findLogNotification($level)) { + $levelName = $level->value; + Assert::fail("The log notification with level [{$levelName}] was unexpectedly found."); } Assert::assertTrue(true); // @phpstan-ignore-line @@ -297,11 +263,7 @@ public function assertLogNotSent(LogLevel $level): static public function assertLogCount(int $count): static { - $logNotifications = collect($this->notifications)->filter(function ($notification): bool { - $content = $notification->toArray(); - - return $content['method'] === 'notifications/message' && isset($content['params']['level']); - }); + $logNotifications = $this->getLogNotifications(); Assert::assertCount( $count, @@ -312,6 +274,38 @@ public function assertLogCount(int $count): static 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 = $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/ContainerResolutionTest.php b/tests/Feature/Logging/ContainerResolutionTest.php deleted file mode 100644 index e3a036fb..00000000 --- a/tests/Feature/Logging/ContainerResolutionTest.php +++ /dev/null @@ -1,146 +0,0 @@ - $errno, - 'message' => $errstr, - 'file' => $errfile, - 'line' => $errline, - ]; - - return true; // Don't execute PHP's internal error handler - }); - - try { - // First, bind LoggingManager in the container (simulating what we want to test) - app()->bind(LoggingManager::class, fn ($app): LoggingManager => new LoggingManager( - $app->make(SessionStoreManager::class) - )); - - // Now resolve it - $manager = app(LoggingManager::class); - - expect($manager)->toBeInstanceOf(LoggingManager::class); - } finally { - restore_error_handler(); - } - - $output = ob_get_clean(); - - // If there was any output, fail with diagnostic info - if ($output !== '' && $output !== false) { - $hexDump = bin2hex(substr($output, 0, 200)); - throw new \RuntimeException(sprintf( - "Unexpected output during LoggingManager resolution!\nLength: %d bytes\nContent: %s\nHex: %s", - strlen($output), - substr($output, 0, 500), - $hexDump - )); - } - - // If there were any errors/warnings, fail with diagnostic info - if ($errors !== []) { - throw new \RuntimeException(sprintf( - "Errors/warnings during LoggingManager resolution:\n%s", - json_encode($errors, JSON_PRETTY_PRINT) - )); - } - - expect($output)->toBe(''); - expect($errors)->toBe([]); -}); - -it('resolves SessionStoreManager from container without producing output', function (): void { - ob_start(); - - $errors = []; - set_error_handler(function (int $errno, string $errstr, string $errfile, int $errline) use (&$errors): bool { - $errors[] = [ - 'type' => $errno, - 'message' => $errstr, - 'file' => $errfile, - 'line' => $errline, - ]; - - return true; - }); - - try { - $manager = app(SessionStoreManager::class); - expect($manager)->toBeInstanceOf(SessionStoreManager::class); - } finally { - restore_error_handler(); - } - - $output = ob_get_clean(); - - if ($output !== '' && $output !== false) { - throw new \RuntimeException(sprintf( - "Unexpected output during SessionStoreManager resolution!\nLength: %d bytes\nContent: %s", - strlen($output), - substr($output, 0, 500) - )); - } - - if ($errors !== []) { - throw new \RuntimeException(sprintf( - "Errors/warnings during SessionStoreManager resolution:\n%s", - json_encode($errors, JSON_PRETTY_PRINT) - )); - } - - expect($output)->toBe(''); -}); - -it('resolves Cache repository without producing output', function (): void { - ob_start(); - - $errors = []; - set_error_handler(function (int $errno, string $errstr, string $errfile, int $errline) use (&$errors): bool { - $errors[] = [ - 'type' => $errno, - 'message' => $errstr, - 'file' => $errfile, - 'line' => $errline, - ]; - - return true; - }); - - try { - $cache = app(\Illuminate\Contracts\Cache\Repository::class); - expect($cache)->toBeInstanceOf(\Illuminate\Contracts\Cache\Repository::class); - } finally { - restore_error_handler(); - } - - $output = ob_get_clean(); - - if ($output !== '' && $output !== false) { - throw new \RuntimeException(sprintf( - "Unexpected output during Cache resolution!\nLength: %d bytes\nContent: %s", - strlen($output), - substr($output, 0, 500) - )); - } - - if ($errors !== []) { - throw new \RuntimeException(sprintf( - "Errors/warnings during Cache resolution:\n%s", - json_encode($errors, JSON_PRETTY_PRINT) - )); - } - - expect($output)->toBe(''); -}); diff --git a/tests/Feature/Logging/LogFilteringTest.php b/tests/Feature/Logging/LogFilteringTest.php index d912158e..92641fa8 100644 --- a/tests/Feature/Logging/LogFilteringTest.php +++ b/tests/Feature/Logging/LogFilteringTest.php @@ -8,16 +8,14 @@ use Laravel\Mcp\Server; use Laravel\Mcp\Server\Support\LoggingManager; use Laravel\Mcp\Server\Tool; - -beforeEach(function (): void { - LoggingManager::setDefaultLevel(LogLevel::Info); -}); +use Tests\Fixtures\ArrayTransport; class LoggingTestServer extends Server { protected array $tools = [ LoggingTestTool::class, StructuredLogTool::class, + LogLevelTestTool::class, ]; } @@ -55,7 +53,19 @@ public function handle(Request $request): \Generator } } -it('sends all log levels with default level', function (): void { +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) @@ -66,32 +76,6 @@ public function handle(Request $request): \Generator ->assertLogNotSent(LogLevel::Debug); }); -it('filters logs based on configured log level - error only', function (): void { - LoggingManager::setDefaultLevel(LogLevel::Error); - - $response = LoggingTestServer::tool(LoggingTestTool::class); - - $response->assertLogCount(2) - ->assertLogSent(LogLevel::Emergency) - ->assertLogSent(LogLevel::Error) - ->assertLogNotSent(LogLevel::Warning) - ->assertLogNotSent(LogLevel::Info) - ->assertLogNotSent(LogLevel::Debug); -}); - -it('filters logs based on the configured log level-debug shows all', function (): void { - LoggingManager::setDefaultLevel(LogLevel::Debug); - - $response = LoggingTestServer::tool(LoggingTestTool::class); - - $response->assertLogCount(5) - ->assertLogSent(LogLevel::Emergency) - ->assertLogSent(LogLevel::Error) - ->assertLogSent(LogLevel::Warning) - ->assertLogSent(LogLevel::Info) - ->assertLogSent(LogLevel::Debug); -}); - it('handles structured log data with arrays', function (): void { $response = LoggingTestServer::tool(StructuredLogTool::class); @@ -103,28 +87,92 @@ public function handle(Request $request): \Generator it('supports string and array data in logs', function (): void { $response = LoggingTestServer::tool(StructuredLogTool::class); - // Just verify both logs were sent $response->assertSentNotification('notifications/message') ->assertLogCount(2); }); -it('resolves logging manager from container correctly during streaming', function (): void { - LoggingManager::setDefaultLevel(LogLevel::Warning); +it('filters logs correctly when 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'); +}); - $response = LoggingTestServer::tool(LoggingTestTool::class); +it('filters logs correctly with default info log level', function (): void { + $transport = new ArrayTransport; + $server = new LoggingTestServer($transport); + $server->start(); - $response->assertLogCount(3) - ->assertLogSent(LogLevel::Emergency) - ->assertLogSent(LogLevel::Error) - ->assertLogSent(LogLevel::Warning) - ->assertLogNotSent(LogLevel::Info) - ->assertLogNotSent(LogLevel::Debug); -}); + $sessionId = 'test-session-'.uniqid(); + $transport->sessionId = $sessionId; -it('handles multiple log notifications efficiently', function (): void { - LoggingManager::setDefaultLevel(LogLevel::Debug); + ($transport->handler)(json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'log-level-test-tool', + 'arguments' => [], + ], + ])); - $response = LoggingTestServer::tool(LoggingTestTool::class); + $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); - $response->assertLogCount(5); + expect($toolResponse['result']['content'][0]['text'])->toContain('info'); }); diff --git a/tests/Feature/Testing/Tools/AssertLogTest.php b/tests/Feature/Testing/Tools/AssertLogTest.php index d9ca2361..c01f6f29 100644 --- a/tests/Feature/Testing/Tools/AssertLogTest.php +++ b/tests/Feature/Testing/Tools/AssertLogTest.php @@ -6,15 +6,10 @@ use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server; -use Laravel\Mcp\Server\Support\LoggingManager; use Laravel\Mcp\Server\Tool; use PHPUnit\Framework\AssertionFailedError; use PHPUnit\Framework\ExpectationFailedException; -beforeEach(function (): void { - LoggingManager::setDefaultLevel(LogLevel::Debug); -}); - class LogAssertServer extends Server { protected array $tools = [ @@ -109,7 +104,7 @@ public function handle(Request $request): Generator $response->assertLogNotSent(LogLevel::Error); })->throws(AssertionFailedError::class); -it('asserts correct log count', function (): void { +it('asserts the correct log count', function (): void { $response = LogAssertServer::tool(MultiLevelLogTool::class); $response->assertLogCount(3); @@ -121,13 +116,13 @@ public function handle(Request $request): Generator $response->assertLogCount(5); })->throws(ExpectationFailedException::class); -it('asserts zero log count when no logs sent', function (): void { +it('asserts zero logs count when no logs sent', function (): void { $response = LogAssertServer::tool(NoLogTool::class); $response->assertLogCount(0); }); -it('asserts single log count', function (): void { +it('asserts a single log count', function (): void { $response = LogAssertServer::tool(SingleLogTool::class); $response->assertLogCount(1) diff --git a/tests/Unit/Content/LoggingMessageTest.php b/tests/Unit/Content/LoggingMessageTest.php index ce4a3e58..7504d2bf 100644 --- a/tests/Unit/Content/LoggingMessageTest.php +++ b/tests/Unit/Content/LoggingMessageTest.php @@ -3,13 +3,13 @@ declare(strict_types=1); use Laravel\Mcp\Enums\LogLevel; -use Laravel\Mcp\Server\Content\LogNotification; +use Laravel\Mcp\Server\Content\Log; use Laravel\Mcp\Server\Prompt; use Laravel\Mcp\Server\Resource; use Laravel\Mcp\Server\Tool; it('creates a logging message with level and data', function (): void { - $message = new LogNotification(LogLevel::Error, 'Something went wrong'); + $message = new Log(LogLevel::Error, 'Something went wrong'); expect($message->level())->toBe(LogLevel::Error) ->and($message->data())->toBe('Something went wrong') @@ -17,7 +17,7 @@ }); it('creates a logging message with optional logger name', function (): void { - $message = new LogNotification(LogLevel::Info, 'Database connected', 'database'); + $message = new Log(LogLevel::Info, 'Database connected', 'database'); expect($message->level())->toBe(LogLevel::Info) ->and($message->data())->toBe('Database connected') @@ -25,21 +25,16 @@ }); it('converts to array with correct notification format', function (): void { - $message = new LogNotification(LogLevel::Warning, 'Low disk space'); + $withoutLogger = new Log(LogLevel::Warning, 'Low disk space'); + $withLogger = new Log(LogLevel::Debug, 'Query executed', 'sql'); - expect($message->toArray())->toEqual([ + expect($withoutLogger->toArray())->toEqual([ 'method' => 'notifications/message', 'params' => [ 'level' => 'warning', 'data' => 'Low disk space', ], - ]); -}); - -it('includes logger in params when provided', function (): void { - $message = new LogNotification(LogLevel::Debug, 'Query executed', 'sql'); - - expect($message->toArray())->toEqual([ + ])->and($withLogger->toArray())->toEqual([ 'method' => 'notifications/message', 'params' => [ 'level' => 'debug', @@ -51,7 +46,7 @@ it('supports array data', function (): void { $data = ['error' => 'Connection failed', 'host' => 'localhost', 'port' => 5432]; - $message = new LogNotification(LogLevel::Error, $data); + $message = new Log(LogLevel::Error, $data); expect($message->data())->toBe($data) ->and($message->toArray()['params']['data'])->toBe($data); @@ -59,48 +54,23 @@ it('supports object data', function (): void { $data = (object) ['name' => 'test', 'value' => 42]; - $message = new LogNotification(LogLevel::Info, $data); + $message = new Log(LogLevel::Info, $data); expect($message->data())->toEqual($data); }); it('casts to string as method name', function (): void { - $message = new LogNotification(LogLevel::Info, 'Test message'); + $message = new Log(LogLevel::Info, 'Test message'); expect((string) $message)->toBe('notifications/message'); }); -it('may be used in tools', function (): void { - $message = new LogNotification(LogLevel::Info, 'Processing'); - - $payload = $message->toTool(new class extends Tool {}); +it('may be used in primitives', function (): void { + $message = new Log(LogLevel::Info, 'Processing'); - expect($payload)->toEqual([ - 'method' => 'notifications/message', - 'params' => [ - 'level' => 'info', - 'data' => 'Processing', - ], - ]); -}); - -it('may be used in prompts', function (): void { - $message = new LogNotification(LogLevel::Warning, 'Deprecation notice'); - - $payload = $message->toPrompt(new class extends Prompt {}); - - expect($payload)->toEqual([ - 'method' => 'notifications/message', - 'params' => [ - 'level' => 'warning', - 'data' => 'Deprecation notice', - ], - ]); -}); - -it('may be used in resources', function (): void { - $message = new LogNotification(LogLevel::Debug, 'Resource loaded'); - $resource = new class extends Resource + $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'; @@ -109,41 +79,39 @@ protected string $title = 'Test File'; protected string $mimeType = 'text/plain'; - }; - - $payload = $message->toResource($resource); + }); - expect($payload)->toEqual([ + $expected = [ 'method' => 'notifications/message', 'params' => [ - 'level' => 'debug', - 'data' => 'Resource loaded', + 'level' => 'info', + 'data' => 'Processing', ], - ]); + ]; + + expect($tool)->toEqual($expected) + ->and($prompt)->toEqual($expected) + ->and($resource)->toEqual($expected); }); it('supports _meta via setMeta', function (): void { - $message = new LogNotification(LogLevel::Error, 'Error occurred'); - $message->setMeta(['trace_id' => 'abc123']); + $withMeta = new Log(LogLevel::Error, 'Error occurred'); + $withMeta->setMeta(['trace_id' => 'abc123']); + + $withoutMeta = new Log(LogLevel::Info, 'Test'); - expect($message->toArray())->toEqual([ + expect($withMeta->toArray())->toEqual([ 'method' => 'notifications/message', 'params' => [ 'level' => 'error', 'data' => 'Error occurred', '_meta' => ['trace_id' => 'abc123'], ], - ]); -}); - -it('does not include _meta if not set', function (): void { - $message = new LogNotification(LogLevel::Info, 'Test'); - - expect($message->toArray()['params'])->not->toHaveKey('_meta'); + ])->and($withoutMeta->toArray()['params'])->not->toHaveKey('_meta'); }); it('supports all log levels', function (LogLevel $level, string $expected): void { - $message = new LogNotification($level, 'Test'); + $message = new Log($level, 'Test'); expect($message->toArray()['params']['level'])->toBe($expected); })->with([ diff --git a/tests/Unit/ResponseTest.php b/tests/Unit/ResponseTest.php index 6e530a98..ef813504 100644 --- a/tests/Unit/ResponseTest.php +++ b/tests/Unit/ResponseTest.php @@ -8,7 +8,7 @@ use Laravel\Mcp\Response; use Laravel\Mcp\ResponseFactory; use Laravel\Mcp\Server\Content\Blob; -use Laravel\Mcp\Server\Content\LogNotification; +use Laravel\Mcp\Server\Content\Log; use Laravel\Mcp\Server\Content\Notification; use Laravel\Mcp\Server\Content\Text; @@ -183,7 +183,7 @@ it('creates a log response', function (): void { $response = Response::log(LogLevel::Error, 'Something went wrong'); - expect($response->content())->toBeInstanceOf(LogNotification::class) + expect($response->content())->toBeInstanceOf(Log::class) ->and($response->isNotification())->toBeTrue() ->and($response->isError())->toBeFalse() ->and($response->role())->toBe(Role::User); @@ -192,7 +192,7 @@ it('creates a log response with logger name', function (): void { $response = Response::log(LogLevel::Info, 'Query executed', 'database'); - expect($response->content())->toBeInstanceOf(LogNotification::class); + expect($response->content())->toBeInstanceOf(Log::class); $content = $response->content(); expect($content->logger())->toBe('database'); @@ -202,7 +202,7 @@ $data = ['error' => 'Connection failed', 'host' => 'localhost']; $response = Response::log(LogLevel::Error, $data); - expect($response->content())->toBeInstanceOf(LogNotification::class); + expect($response->content())->toBeInstanceOf(Log::class); $content = $response->content(); expect($content->data())->toBe($data); diff --git a/tests/Unit/Server/Support/LoggingManagerTest.php b/tests/Unit/Server/Support/LoggingManagerTest.php index 91fe558f..e3d0a359 100644 --- a/tests/Unit/Server/Support/LoggingManagerTest.php +++ b/tests/Unit/Server/Support/LoggingManagerTest.php @@ -7,10 +7,6 @@ use Laravel\Mcp\Server\Support\LoggingManager; use Laravel\Mcp\Server\Support\SessionStoreManager; -beforeEach(function (): void { - LoggingManager::setDefaultLevel(LogLevel::Info); -}); - test('it returns the default level for the new session', function (): void { $manager = new LoggingManager(new SessionStoreManager(Cache::driver(), 'session-1')); @@ -53,25 +49,9 @@ ->and($manager->shouldLog(LogLevel::Debug))->toBeFalse(); }); -test('it can change the default level', function (): void { - LoggingManager::setDefaultLevel(LogLevel::Warning); - - $manager1 = new LoggingManager(new SessionStoreManager(Cache::driver(), 'new-session')); - $manager2 = new LoggingManager(new SessionStoreManager(Cache::driver())); - - expect($manager1->getLevel())->toBe(LogLevel::Warning) - ->and($manager2->getLevel())->toBe(LogLevel::Warning); -}); - test('setLevel ignores null session id', function (): void { $manager = new LoggingManager(new SessionStoreManager(Cache::driver())); $manager->setLevel(LogLevel::Debug); expect($manager->getLevel())->toBe(LogLevel::Info); }); - -test('it can get default level', function (): void { - LoggingManager::setDefaultLevel(LogLevel::Warning); - - expect(LoggingManager::getDefaultLevel())->toBe(LogLevel::Warning); -}); diff --git a/tests/Unit/Server/Support/SessionStoreManagerTest.php b/tests/Unit/Server/Support/SessionStoreManagerTest.php index fb8be873..c47f0189 100644 --- a/tests/Unit/Server/Support/SessionStoreManagerTest.php +++ b/tests/Unit/Server/Support/SessionStoreManagerTest.php @@ -61,32 +61,14 @@ expect($session->sessionId())->toBe('my-session-id'); }); -test('it handles null session id gracefully for set', function (): void { +test('it handles null session id gracefully', function (): void { $session = new SessionStoreManager(Cache::driver()); $session->set('key', 'value'); - - expect($session->get('key'))->toBeNull(); -}); - -test('it handles null session id gracefully for get', function (): void { - $session = new SessionStoreManager(Cache::driver()); - - expect($session->get('key', 'default'))->toBe('default'); -}); - -test('it handles null session id gracefully for has', function (): void { - $session = new SessionStoreManager(Cache::driver()); - - expect($session->has('key'))->toBeFalse(); -}); - -test('it handles null session id gracefully for forget', function (): void { - $session = new SessionStoreManager(Cache::driver()); - $session->forget('key'); - expect(true)->toBeTrue(); + expect($session->get('key', 'default'))->toBe('default') + ->and($session->has('key'))->toBeFalse(); }); test('it can store complex values', function (): void { From d74510915935e2e92796b0cd1a6d3201f4b66e15 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Tue, 2 Dec 2025 07:14:17 +0530 Subject: [PATCH 6/8] Change Namespace --- src/Server/McpServiceProvider.php | 8 ++++---- src/Server/Support/LoggingManager.php | 2 +- ...ssionStoreManager.php => SessionStore.php} | 2 +- tests/Feature/Logging/SetLogLevelTest.php | 8 ++++---- tests/Unit/Methods/SetLogLevelTest.php | 16 +++++++-------- .../Server/Support/LoggingManagerTest.php | 16 +++++++-------- .../Support/SessionStoreManagerTest.php | 20 +++++++++---------- 7 files changed, 36 insertions(+), 36 deletions(-) rename src/Server/Support/{SessionStoreManager.php => SessionStore.php} (98%) diff --git a/src/Server/McpServiceProvider.php b/src/Server/McpServiceProvider.php index bcb23d6a..46a54e44 100644 --- a/src/Server/McpServiceProvider.php +++ b/src/Server/McpServiceProvider.php @@ -15,7 +15,7 @@ use Laravel\Mcp\Console\Commands\StartCommand; use Laravel\Mcp\Request; use Laravel\Mcp\Server\Support\LoggingManager; -use Laravel\Mcp\Server\Support\SessionStoreManager; +use Laravel\Mcp\Server\Support\SessionStore; class McpServiceProvider extends ServiceProvider { @@ -92,7 +92,7 @@ protected function registerContainerCallbacks(): void protected function registerSessionBindings(): void { - $this->app->bind(SessionStoreManager::class, function ($app): Support\SessionStoreManager { + $this->app->bind(SessionStore::class, function ($app): Support\SessionStore { $sessionId = null; if ($app->bound('mcp.request')) { @@ -101,14 +101,14 @@ protected function registerSessionBindings(): void $sessionId = $request->sessionId(); } - return new SessionStoreManager( + return new SessionStore( $app->make(Repository::class), $sessionId ); }); $this->app->bind(LoggingManager::class, fn ($app): LoggingManager => new LoggingManager( - $app->make(SessionStoreManager::class) + $app->make(SessionStore::class) )); } diff --git a/src/Server/Support/LoggingManager.php b/src/Server/Support/LoggingManager.php index b75ef30c..fb450910 100644 --- a/src/Server/Support/LoggingManager.php +++ b/src/Server/Support/LoggingManager.php @@ -12,7 +12,7 @@ class LoggingManager private const DEFAULT_LEVEL = LogLevel::Info; - public function __construct(protected SessionStoreManager $session) + public function __construct(protected SessionStore $session) { // } diff --git a/src/Server/Support/SessionStoreManager.php b/src/Server/Support/SessionStore.php similarity index 98% rename from src/Server/Support/SessionStoreManager.php rename to src/Server/Support/SessionStore.php index cf1f99d9..50213916 100644 --- a/src/Server/Support/SessionStoreManager.php +++ b/src/Server/Support/SessionStore.php @@ -6,7 +6,7 @@ use Illuminate\Contracts\Cache\Repository as Cache; -class SessionStoreManager +class SessionStore { protected const CACHE_PREFIX = 'mcp'; diff --git a/tests/Feature/Logging/SetLogLevelTest.php b/tests/Feature/Logging/SetLogLevelTest.php index a245d8c4..6612ca5b 100644 --- a/tests/Feature/Logging/SetLogLevelTest.php +++ b/tests/Feature/Logging/SetLogLevelTest.php @@ -5,7 +5,7 @@ use Illuminate\Support\Facades\Cache; use Laravel\Mcp\Enums\LogLevel; use Laravel\Mcp\Server\Support\LoggingManager; -use Laravel\Mcp\Server\Support\SessionStoreManager; +use Laravel\Mcp\Server\Support\SessionStore; use Tests\Fixtures\ArrayTransport; use Tests\Fixtures\ExampleServer; @@ -34,7 +34,7 @@ expect($response)->toHaveKey('result') ->and($response['id'])->toBe(1); - $manager = new LoggingManager(new SessionStoreManager(Cache::driver(), $sessionId)); + $manager = new LoggingManager(new SessionStore(Cache::driver(), $sessionId)); expect($manager->getLevel())->toBe(LogLevel::Error); }); @@ -66,8 +66,8 @@ 'params' => ['level' => 'error'], ])); - $manager1 = new LoggingManager(new SessionStoreManager(Cache::driver(), $sessionId1)); - $manager2 = new LoggingManager(new SessionStoreManager(Cache::driver(), $sessionId2)); + $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/Unit/Methods/SetLogLevelTest.php b/tests/Unit/Methods/SetLogLevelTest.php index c1fc3b4d..627f9f1d 100644 --- a/tests/Unit/Methods/SetLogLevelTest.php +++ b/tests/Unit/Methods/SetLogLevelTest.php @@ -8,7 +8,7 @@ use Laravel\Mcp\Server\Methods\SetLogLevel; use Laravel\Mcp\Server\ServerContext; use Laravel\Mcp\Server\Support\LoggingManager; -use Laravel\Mcp\Server\Support\SessionStoreManager; +use Laravel\Mcp\Server\Support\SessionStore; use Laravel\Mcp\Server\Transport\JsonRpcRequest; use Laravel\Mcp\Server\Transport\JsonRpcResponse; @@ -35,7 +35,7 @@ prompts: [], ); - $loggingManager = new LoggingManager(new SessionStoreManager(Cache::driver(), 'session-123')); + $loggingManager = new LoggingManager(new SessionStore(Cache::driver(), 'session-123')); $method = new SetLogLevel($loggingManager); $response = $method->handle($request, $context); @@ -45,7 +45,7 @@ expect($payload['id'])->toEqual(1) ->and($payload['result'])->toEqual((object) []); - $manager = new LoggingManager(new SessionStoreManager(Cache::driver(), 'session-123')); + $manager = new LoggingManager(new SessionStore(Cache::driver(), 'session-123')); expect($manager->getLevel())->toBe(LogLevel::Debug); }); @@ -72,13 +72,13 @@ prompts: [], ); - $loggingManager = new LoggingManager(new SessionStoreManager(Cache::driver(), 'session-456')); + $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 SessionStoreManager(Cache::driver(), 'session-456')); + $manager = new LoggingManager(new SessionStore(Cache::driver(), 'session-456')); expect($manager->getLevel())->toBe($expectedLevel); })->with([ ['emergency', LogLevel::Emergency], @@ -116,7 +116,7 @@ prompts: [], ); - $loggingManager = new LoggingManager(new SessionStoreManager(Cache::driver(), 'session-789')); + $loggingManager = new LoggingManager(new SessionStore(Cache::driver(), 'session-789')); $method = new SetLogLevel($loggingManager); try { @@ -164,7 +164,7 @@ prompts: [], ); - $loggingManager = new LoggingManager(new SessionStoreManager(Cache::driver(), 'session-999')); + $loggingManager = new LoggingManager(new SessionStore(Cache::driver(), 'session-999')); $method = new SetLogLevel($loggingManager); try { @@ -205,7 +205,7 @@ prompts: [], ); - $loggingManager = new LoggingManager(new SessionStoreManager(Cache::driver(), 'session-111')); + $loggingManager = new LoggingManager(new SessionStore(Cache::driver(), 'session-111')); $method = new SetLogLevel($loggingManager); $method->handle($request, $context); }); diff --git a/tests/Unit/Server/Support/LoggingManagerTest.php b/tests/Unit/Server/Support/LoggingManagerTest.php index e3d0a359..db4fcc8c 100644 --- a/tests/Unit/Server/Support/LoggingManagerTest.php +++ b/tests/Unit/Server/Support/LoggingManagerTest.php @@ -5,24 +5,24 @@ use Illuminate\Support\Facades\Cache; use Laravel\Mcp\Enums\LogLevel; use Laravel\Mcp\Server\Support\LoggingManager; -use Laravel\Mcp\Server\Support\SessionStoreManager; +use Laravel\Mcp\Server\Support\SessionStore; test('it returns the default level for the new session', function (): void { - $manager = new LoggingManager(new SessionStoreManager(Cache::driver(), 'session-1')); + $manager = new LoggingManager(new SessionStore(Cache::driver(), 'session-1')); expect($manager->getLevel())->toBe(LogLevel::Info); }); test('it can set and get log level for a session', function (): void { - $manager = new LoggingManager(new SessionStoreManager(Cache::driver(), 'session-1')); + $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 SessionStoreManager(Cache::driver(), 'session-1')); - $manager2 = new LoggingManager(new SessionStoreManager(Cache::driver(), 'session-2')); + $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); @@ -32,7 +32,7 @@ }); test('it correctly determines if a log should be sent', function (): void { - $manager = new LoggingManager(new SessionStoreManager(Cache::driver(), 'session-1')); + $manager = new LoggingManager(new SessionStore(Cache::driver(), 'session-1')); $manager->setLevel(LogLevel::Info); expect($manager->shouldLog(LogLevel::Emergency))->toBeTrue() @@ -42,7 +42,7 @@ }); test('it uses default level for null session id', function (): void { - $manager = new LoggingManager(new SessionStoreManager(Cache::driver())); + $manager = new LoggingManager(new SessionStore(Cache::driver())); expect($manager->getLevel())->toBe(LogLevel::Info) ->and($manager->shouldLog(LogLevel::Info))->toBeTrue() @@ -50,7 +50,7 @@ }); test('setLevel ignores null session id', function (): void { - $manager = new LoggingManager(new SessionStoreManager(Cache::driver())); + $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 index c47f0189..b615b4ac 100644 --- a/tests/Unit/Server/Support/SessionStoreManagerTest.php +++ b/tests/Unit/Server/Support/SessionStoreManagerTest.php @@ -3,14 +3,14 @@ declare(strict_types=1); use Illuminate\Support\Facades\Cache; -use Laravel\Mcp\Server\Support\SessionStoreManager; +use Laravel\Mcp\Server\Support\SessionStore; beforeEach(function (): void { Cache::flush(); }); test('it can set and get values for a session', function (): void { - $session = new SessionStoreManager(Cache::driver(), 'session-1'); + $session = new SessionStore(Cache::driver(), 'session-1'); $session->set('key', 'value'); @@ -18,14 +18,14 @@ }); test('it returns the default value when the key does not exist', function (): void { - $session = new SessionStoreManager(Cache::driver(), 'session-1'); + $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 SessionStoreManager(Cache::driver(), 'session-1'); - $session2 = new SessionStoreManager(Cache::driver(), 'session-2'); + $session1 = new SessionStore(Cache::driver(), 'session-1'); + $session2 = new SessionStore(Cache::driver(), 'session-2'); $session1->set('key', 'value-1'); $session2->set('key', 'value-2'); @@ -35,7 +35,7 @@ }); test('it can check if a key exists', function (): void { - $session = new SessionStoreManager(Cache::driver(), 'session-1'); + $session = new SessionStore(Cache::driver(), 'session-1'); expect($session->has('key'))->toBeFalse(); @@ -45,7 +45,7 @@ }); test('it can forget a key', function (): void { - $session = new SessionStoreManager(Cache::driver(), 'session-1'); + $session = new SessionStore(Cache::driver(), 'session-1'); $session->set('key', 'value'); @@ -56,13 +56,13 @@ }); test('it returns session id', function (): void { - $session = new SessionStoreManager(Cache::driver(), 'my-session-id'); + $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 SessionStoreManager(Cache::driver()); + $session = new SessionStore(Cache::driver()); $session->set('key', 'value'); $session->forget('key'); @@ -72,7 +72,7 @@ }); test('it can store complex values', function (): void { - $session = new SessionStoreManager(Cache::driver(), 'session-1'); + $session = new SessionStore(Cache::driver(), 'session-1'); $session->set('array', ['foo' => 'bar', 'baz' => [1, 2, 3]]); $session->set('object', (object) ['name' => 'test']); From 9fc43707655a5bb72dd5dd586841d37251f68a80 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Wed, 3 Dec 2025 18:48:37 +0530 Subject: [PATCH 7/8] Formatting --- src/Server/Testing/TestResponse.php | 3 +- tests/Feature/Logging/SetLogLevelTest.php | 1 + tests/Feature/Testing/Tools/AssertLogTest.php | 28 ++++---- tests/Unit/Content/LoggingMessageTest.php | 6 +- tests/Unit/Methods/SetLogLevelTest.php | 2 +- tests/Unit/Server/Enums/LogLevelTest.php | 69 +++++++++---------- 6 files changed, 55 insertions(+), 54 deletions(-) diff --git a/src/Server/Testing/TestResponse.php b/src/Server/Testing/TestResponse.php index ff614922..f3c73f98 100644 --- a/src/Server/Testing/TestResponse.php +++ b/src/Server/Testing/TestResponse.php @@ -6,6 +6,7 @@ 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; @@ -299,7 +300,7 @@ protected function findLogNotification(LogLevel $level, ?string $contains = null return true; } - $data = $params['data'] ?? ''; + $data = Arr::get($params, 'data', ''); $dataString = is_string($data) ? $data : (string) json_encode($data); return $dataString !== '' && str_contains($dataString, $contains); diff --git a/tests/Feature/Logging/SetLogLevelTest.php b/tests/Feature/Logging/SetLogLevelTest.php index 6612ca5b..9a5f2bf5 100644 --- a/tests/Feature/Logging/SetLogLevelTest.php +++ b/tests/Feature/Logging/SetLogLevelTest.php @@ -35,6 +35,7 @@ ->and($response['id'])->toBe(1); $manager = new LoggingManager(new SessionStore(Cache::driver(), $sessionId)); + expect($manager->getLevel())->toBe(LogLevel::Error); }); diff --git a/tests/Feature/Testing/Tools/AssertLogTest.php b/tests/Feature/Testing/Tools/AssertLogTest.php index c01f6f29..f5c241e1 100644 --- a/tests/Feature/Testing/Tools/AssertLogTest.php +++ b/tests/Feature/Testing/Tools/AssertLogTest.php @@ -10,7 +10,7 @@ use PHPUnit\Framework\AssertionFailedError; use PHPUnit\Framework\ExpectationFailedException; -class LogAssertServer extends Server +class LogServer extends Server { protected array $tools = [ MultiLevelLogTool::class, @@ -64,7 +64,7 @@ public function handle(Request $request): Generator } it('asserts log was sent with a specific level', function (): void { - $response = LogAssertServer::tool(MultiLevelLogTool::class); + $response = LogServer::tool(MultiLevelLogTool::class); $response->assertLogSent(LogLevel::Error); $response->assertLogSent(LogLevel::Warning); @@ -72,7 +72,7 @@ public function handle(Request $request): Generator }); it('asserts log was sent with level and message content', function (): void { - $response = LogAssertServer::tool(MultiLevelLogTool::class); + $response = LogServer::tool(MultiLevelLogTool::class); $response->assertLogSent(LogLevel::Error, 'Error occurred') ->assertLogSent(LogLevel::Warning, 'Warning message') @@ -80,64 +80,64 @@ public function handle(Request $request): Generator }); it('fails when asserting log sent with wrong level', function (): void { - $response = LogAssertServer::tool(MultiLevelLogTool::class); + $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 = LogAssertServer::tool(MultiLevelLogTool::class); + $response = LogServer::tool(MultiLevelLogTool::class); $response->assertLogSent(LogLevel::Error, 'Wrong message'); })->throws(AssertionFailedError::class); it('asserts log was not sent', function (): void { - $response = LogAssertServer::tool(MultiLevelLogTool::class); + $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 = LogAssertServer::tool(MultiLevelLogTool::class); + $response = LogServer::tool(MultiLevelLogTool::class); $response->assertLogNotSent(LogLevel::Error); })->throws(AssertionFailedError::class); it('asserts the correct log count', function (): void { - $response = LogAssertServer::tool(MultiLevelLogTool::class); + $response = LogServer::tool(MultiLevelLogTool::class); $response->assertLogCount(3); }); it('fails when asserting wrong log count', function (): void { - $response = LogAssertServer::tool(MultiLevelLogTool::class); + $response = LogServer::tool(MultiLevelLogTool::class); $response->assertLogCount(5); })->throws(ExpectationFailedException::class); it('asserts zero logs count when no logs sent', function (): void { - $response = LogAssertServer::tool(NoLogTool::class); + $response = LogServer::tool(NoLogTool::class); $response->assertLogCount(0); }); it('asserts a single log count', function (): void { - $response = LogAssertServer::tool(SingleLogTool::class); + $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 = LogAssertServer::tool(ArrayDataLogTool::class); + $response = LogServer::tool(ArrayDataLogTool::class); $response->assertLogSent(LogLevel::Error, 'Connection failed') ->assertLogSent(LogLevel::Error, 'localhost'); }); it('chains multiple log assertions', function (): void { - $response = LogAssertServer::tool(MultiLevelLogTool::class); + $response = LogServer::tool(MultiLevelLogTool::class); $response->assertLogCount(3) ->assertLogSent(LogLevel::Error) @@ -148,7 +148,7 @@ public function handle(Request $request): Generator }); it('can combine log assertions with other assertions', function (): void { - $response = LogAssertServer::tool(MultiLevelLogTool::class); + $response = LogServer::tool(MultiLevelLogTool::class); $response->assertSee('Done') ->assertLogCount(3) diff --git a/tests/Unit/Content/LoggingMessageTest.php b/tests/Unit/Content/LoggingMessageTest.php index 7504d2bf..2a7d4f67 100644 --- a/tests/Unit/Content/LoggingMessageTest.php +++ b/tests/Unit/Content/LoggingMessageTest.php @@ -8,7 +8,7 @@ use Laravel\Mcp\Server\Resource; use Laravel\Mcp\Server\Tool; -it('creates a logging message with level and data', function (): void { +it('creates a log with level and data', function (): void { $message = new Log(LogLevel::Error, 'Something went wrong'); expect($message->level())->toBe(LogLevel::Error) @@ -16,7 +16,7 @@ ->and($message->logger())->toBeNull(); }); -it('creates a logging message with optional logger name', function (): void { +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) @@ -24,7 +24,7 @@ ->and($message->logger())->toBe('database'); }); -it('converts to array with correct notification format', function (): void { +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'); diff --git a/tests/Unit/Methods/SetLogLevelTest.php b/tests/Unit/Methods/SetLogLevelTest.php index 627f9f1d..f6635273 100644 --- a/tests/Unit/Methods/SetLogLevelTest.php +++ b/tests/Unit/Methods/SetLogLevelTest.php @@ -91,7 +91,7 @@ ['debug', LogLevel::Debug], ]); -it('throws exception for missing level parameter', function (): void { +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); diff --git a/tests/Unit/Server/Enums/LogLevelTest.php b/tests/Unit/Server/Enums/LogLevelTest.php index c3c5e8bf..d37b0c3f 100644 --- a/tests/Unit/Server/Enums/LogLevelTest.php +++ b/tests/Unit/Server/Enums/LogLevelTest.php @@ -4,49 +4,48 @@ use Laravel\Mcp\Enums\LogLevel; -test('it has correct severity values', function (): void { - expect(LogLevel::Emergency->severity())->toBe(0); - expect(LogLevel::Alert->severity())->toBe(1); - expect(LogLevel::Critical->severity())->toBe(2); - expect(LogLevel::Error->severity())->toBe(3); - expect(LogLevel::Warning->severity())->toBe(4); - expect(LogLevel::Notice->severity())->toBe(5); - expect(LogLevel::Info->severity())->toBe(6); - expect(LogLevel::Debug->severity())->toBe(7); +it('has correct severity values', function (): void { + expect(LogLevel::Emergency->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); }); -test('it correctly determines if a log should be sent', function (): void { - expect(LogLevel::Emergency->shouldLog(LogLevel::Info))->toBeTrue(); - expect(LogLevel::Error->shouldLog(LogLevel::Info))->toBeTrue(); - expect(LogLevel::Info->shouldLog(LogLevel::Info))->toBeTrue(); - expect(LogLevel::Debug->shouldLog(LogLevel::Info))->toBeFalse(); +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(); - expect(LogLevel::Error->shouldLog(LogLevel::Error))->toBeTrue(); - expect(LogLevel::Warning->shouldLog(LogLevel::Error))->toBeFalse(); - expect(LogLevel::Info->shouldLog(LogLevel::Error))->toBeFalse(); - - expect(LogLevel::Emergency->shouldLog(LogLevel::Debug))->toBeTrue(); - expect(LogLevel::Debug->shouldLog(LogLevel::Debug))->toBeTrue(); - expect(LogLevel::Info->shouldLog(LogLevel::Debug))->toBeTrue(); }); -test('it can be created from string', function (): void { - expect(LogLevel::fromString('emergency'))->toBe(LogLevel::Emergency); - expect(LogLevel::fromString('alert'))->toBe(LogLevel::Alert); - expect(LogLevel::fromString('critical'))->toBe(LogLevel::Critical); - expect(LogLevel::fromString('error'))->toBe(LogLevel::Error); - expect(LogLevel::fromString('warning'))->toBe(LogLevel::Warning); - expect(LogLevel::fromString('notice'))->toBe(LogLevel::Notice); - expect(LogLevel::fromString('info'))->toBe(LogLevel::Info); - expect(LogLevel::fromString('debug'))->toBe(LogLevel::Debug); +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); }); -test('it handles case insensitive string conversion', function (): void { - expect(LogLevel::fromString('EMERGENCY'))->toBe(LogLevel::Emergency); - expect(LogLevel::fromString('Error'))->toBe(LogLevel::Error); - expect(LogLevel::fromString('INFO'))->toBe(LogLevel::Info); +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); }); -test('it throws exception for invalid level string', function (): void { +it('throws exception for invalid level string', function (): void { LogLevel::fromString('invalid'); })->throws(ValueError::class); From 8b907fc87bfe422d4ef937b6c2594bc966813095 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Wed, 3 Dec 2025 19:11:36 +0530 Subject: [PATCH 8/8] Cleanup Request In Testing --- src/Server/Testing/PendingTestResponse.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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'); + } } }