From b44693ae239f8cb8bee47b137e09c098eee25797 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 27 Jul 2026 16:34:55 +0200 Subject: [PATCH] [Server] Throw the SDK's own RuntimeException from FileSessionStore The constructor guarded its unwritable-directory case with the global `\RuntimeException`, the only throw in `src/` still outside `Mcp\Exception\ExceptionInterface`. A consumer wrapping server setup in a `catch (ExceptionInterface)` missed it and got an unhandled SPL exception on a misconfigured session directory instead. `Mcp\Exception\RuntimeException` extends `\RuntimeException`, so this is additive: existing `catch (\RuntimeException)` blocks keep working. The constructor had no test coverage at all, so this adds one for the throw plus the directory-creation and write/read paths around it. --- src/Server/Session/FileSessionStore.php | 3 +- .../Server/Session/FileSessionStoreTest.php | 90 +++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/Server/Session/FileSessionStoreTest.php diff --git a/src/Server/Session/FileSessionStore.php b/src/Server/Session/FileSessionStore.php index aa99aba8..9716e3d6 100644 --- a/src/Server/Session/FileSessionStore.php +++ b/src/Server/Session/FileSessionStore.php @@ -11,6 +11,7 @@ namespace Mcp\Server\Session; +use Mcp\Exception\RuntimeException; use Mcp\Server\NativeClock; use Psr\Clock\ClockInterface; use Symfony\Component\Uid\Uuid; @@ -31,7 +32,7 @@ public function __construct( } if (!is_dir($this->directory) || !is_writable($this->directory)) { - throw new \RuntimeException(\sprintf('Session directory "%s" is not writable.', $this->directory)); + throw new RuntimeException(\sprintf('Session directory "%s" is not writable.', $this->directory)); } } diff --git a/tests/Unit/Server/Session/FileSessionStoreTest.php b/tests/Unit/Server/Session/FileSessionStoreTest.php new file mode 100644 index 00000000..1e0caa75 --- /dev/null +++ b/tests/Unit/Server/Session/FileSessionStoreTest.php @@ -0,0 +1,90 @@ +directory = sys_get_temp_dir().'/mcp-file-session-store-'.bin2hex(random_bytes(6)); + } + + protected function tearDown(): void + { + if (!is_dir($this->directory)) { + return; + } + + @chmod($this->directory, 0775); + + foreach (glob($this->directory.'/*') ?: [] as $file) { + @unlink($file); + } + + @rmdir($this->directory); + } + + #[TestDox('creates the session directory when it does not exist yet')] + public function testCreatesMissingDirectory(): void + { + new FileSessionStore($this->directory); + + $this->assertDirectoryExists($this->directory); + } + + #[TestDox('round-trips a session payload through the filesystem')] + public function testWriteThenRead(): void + { + $store = new FileSessionStore($this->directory); + $id = new UuidV4(); + + $store->write($id, 'payload'); + + $this->assertTrue($store->exists($id)); + $this->assertSame('payload', $store->read($id)); + } + + #[TestDox('rejects an unwritable directory with the SDK\'s own exception')] + public function testUnwritableDirectoryThrowsPackageException(): void + { + mkdir($this->directory, 0775, true); + chmod($this->directory, 0555); + clearstatcache(true, $this->directory); + + if (is_writable($this->directory)) { + $this->markTestSkipped('Permission bits do not restrict writes here (running as root, or a filesystem that ignores them).'); + } + + // The store's only throw must stay inside the package hierarchy, so a + // consumer catching ExceptionInterface sees it rather than a bare SPL + // RuntimeException escaping the SDK. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage(\sprintf('Session directory "%s" is not writable.', $this->directory)); + + try { + new FileSessionStore($this->directory); + } catch (RuntimeException $e) { + $this->assertInstanceOf(ExceptionInterface::class, $e); + + throw $e; + } + } +}