diff --git a/src/memory/__tests__/persistence.test.ts b/src/memory/__tests__/persistence.test.ts index 24ac8c2a57..9f867bed5b 100644 --- a/src/memory/__tests__/persistence.test.ts +++ b/src/memory/__tests__/persistence.test.ts @@ -211,6 +211,11 @@ describe("memory persistence over the protocol", () => { // which is not the ENOENT that means "no graph yet". await fs.mkdir(filePath); const { client } = await open(); + // Node 26 appends the path to the message; earlier versions do not + // (#5076). The server passes Node's message through either way. + const bare = "EISDIR: illegal operation on a directory, read"; + const nodeMajor = Number(process.versions.node.split(".")[0]); + const expected = nodeMajor >= 26 ? `${bare} '${filePath}'` : bare; for (const [name, args] of [ ["read_graph", {}], @@ -220,9 +225,7 @@ describe("memory persistence over the protocol", () => { ] as const) { const result = await call(client, name, args); expect(result.isError).toBe(true); - expect(textOf(result)).toBe( - "EISDIR: illegal operation on a directory, read", - ); + expect(textOf(result)).toBe(expected); } await expect( client.readResource({ uri: "memory://knowledge-graph" }), diff --git a/src/time/README.md b/src/time/README.md index 43775f71fa..0db4b0cce4 100644 --- a/src/time/README.md +++ b/src/time/README.md @@ -20,6 +20,8 @@ Requires MCP Python SDK 1.x (`mcp>=1.29.0,<2`). SDK 2.0 renamed APIs this server - `time` (string): Time in 24-hour format (HH:MM) - `target_timezone` (string): Target IANA timezone name +A timezone name, here and in `--local-timezone`, must be an exact IANA key, case included: `Europe/Warsaw` is accepted and `europe/warsaw` is rejected on every platform, including macOS, whose case-insensitive filesystem would otherwise resolve it. + ## Installation ### Using uv (recommended) diff --git a/src/time/src/mcp_server_time/server.py b/src/time/src/mcp_server_time/server.py index 461bc4a2ae..f224b00e44 100644 --- a/src/time/src/mcp_server_time/server.py +++ b/src/time/src/mcp_server_time/server.py @@ -1,11 +1,12 @@ from datetime import datetime, timedelta +from functools import cache from importlib.metadata import version from enum import Enum import json import sys from typing import Sequence -from zoneinfo import ZoneInfo, ZoneInfoNotFoundError +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError, available_timezones from tzlocal import get_localzone_name # ← returns "Europe/Paris", etc. from mcp.server import Server @@ -65,9 +66,30 @@ def get_local_tz(local_tz_override: str | None = None) -> ZoneInfo: return ZoneInfo("UTC") +@cache +def known_timezones() -> frozenset[str]: + """The exact IANA keys this host's tz database holds, read once.""" + return frozenset(available_timezones()) + + +def load_zoneinfo(timezone_name: str) -> ZoneInfo: + """`ZoneInfo(timezone_name)`, accepting only an exact IANA key. + + zoneinfo looks a key up as a file path, so on a case-insensitive + filesystem (macOS's APFS) `europe/warsaw` resolves to `Europe/Warsaw` + while Linux rejects it (#5060). Checking the key against the database's + own list makes every platform reject it. `ZoneInfo` runs first so a path + or a directory keeps the error zoneinfo gives it. + """ + zone = ZoneInfo(timezone_name) + if timezone_name not in known_timezones(): + raise ZoneInfoNotFoundError(f"No time zone found with key {timezone_name}") + return zone + + def get_zoneinfo(timezone_name: str) -> ZoneInfo: try: - return ZoneInfo(timezone_name) + return load_zoneinfo(timezone_name) except Exception as e: raise McpError( ErrorData(code=INVALID_PARAMS, message=f"Invalid timezone: {str(e)}") @@ -155,7 +177,7 @@ async def serve(local_timezone: str | None = None) -> None: # bad key (unknown name, a tzdata directory, a path, a null byte), and # its messages can carry a filesystem path, so none of them is shown. try: - ZoneInfo(local_timezone) + load_zoneinfo(local_timezone) except (ZoneInfoNotFoundError, ValueError, OSError): sys.exit( f"Error: invalid --local-timezone {local_timezone!r}: " diff --git a/src/time/tests/test_protocol.py b/src/time/tests/test_protocol.py index f76aa58051..f80cb35a1c 100644 --- a/src/time/tests/test_protocol.py +++ b/src/time/tests/test_protocol.py @@ -241,6 +241,7 @@ async def test_empty_local_timezone_override_is_treated_as_absent() -> None: "Not/AZone", # unknown name: ZoneInfoNotFoundError "America", # a tzdata directory, not a zone: OSError "../etc/passwd", # outside TZPATH: ValueError + "europe/warsaw", # not the exact key, on any filesystem (#5060) ], ) async def test_invalid_local_timezone_fails_before_the_transport_opens( diff --git a/src/time/tests/test_server.py b/src/time/tests/test_server.py index a6a6c675f4..2b80df3fa4 100644 --- a/src/time/tests/test_server.py +++ b/src/time/tests/test_server.py @@ -4,7 +4,12 @@ from unittest.mock import patch from zoneinfo import ZoneInfo -from mcp_server_time.server import TimeServer, get_local_tz +from mcp_server_time.server import ( + TimeServer, + get_local_tz, + get_zoneinfo, + known_timezones, +) @pytest.mark.parametrize( @@ -525,3 +530,17 @@ def test_get_local_tz_various_timezones(mock_get_localzone, timezone_name): result = get_local_tz() assert str(result) == timezone_name assert isinstance(result, ZoneInfo) + + +def test_get_zoneinfo_rejects_a_key_the_tz_database_does_not_list(): + # #5060: on a case-insensitive filesystem `ZoneInfo` resolves a key that + # is not an exact IANA name. Simulate that on any platform: `ZoneInfo` + # accepts "Europe/Warsaw", but the database's key list does not hold it. + without_warsaw = known_timezones() - {"Europe/Warsaw"} + with patch("mcp_server_time.server.known_timezones", return_value=without_warsaw): + with pytest.raises( + McpError, + match="Invalid timezone: 'No time zone found with key Europe/Warsaw'", + ): + get_zoneinfo("Europe/Warsaw") + assert str(get_zoneinfo("Europe/Warsaw")) == "Europe/Warsaw"