Skip to content

fix(time): stop discarding McpError in the call_tool handler - #4820

Open
tounsils wants to merge 2 commits into
modelcontextprotocol:mainfrom
tounsils:fix/time-preserve-mcperror
Open

tounsils wants to merge 2 commits into
modelcontextprotocol:mainfrom
tounsils:fix/time-preserve-mcperror

Conversation

@tounsils

Copy link
Copy Markdown

Description

get_zoneinfo raises a typed error so a caller can tell a bad argument from a server fault:

raise McpError(ErrorData(code=INVALID_PARAMS, message=f"Invalid timezone: {str(e)}"))

The call_tool handler then catches it with a blanket except Exception and re-raises it as a plain ValueError (src/time/src/mcp_server_time/server.py:215). The code is discarded and the message is prefixed a second time:

get_current_time(timezone="Invalid/Zone")

before: "Error processing mcp-server-time query: Invalid timezone: 'No time zone found with key Invalid/Zone'"
after:  "Invalid timezone: 'No time zone found with key Invalid/Zone'"

The fetch server, the other Python server in this repo, already lets McpError propagate untouched.

Changes

  • Re-raise McpError ahead of the blanket handler. Every other exception keeps the existing prefix, so nothing else changes on the wire.
  • Extract build_server() from serve(). The handler previously had no test coverage because it was only reachable through serve(), which binds stdio; the extraction lets a test drive it over an in-memory session. serve() is otherwise unchanged and still does exactly what it did.

Testing

uv run pyright src    # 0 errors, 0 warnings
uv run pytest test/   # 41 passed (38 before), 0 skipped

Three tests added against the handler: the McpError path, the generic-error path, and a success path. The first was confirmed red with only the except McpError: raise removed:

FAILED test/time_server_test.py::test_call_tool_preserves_mcp_error_for_invalid_timezone
  assert "Error processing mcp-server-time query: Invalid timezone: ...".startswith("Invalid timezone:")

The new tests pin anyio_backend to asyncio via a fixture, since trio is not a dev dependency here and the anyio plugin would otherwise parametrize over both.

Motivation and Context

The typed error exists so a client can distinguish a correctable argument from a server fault. Flattening it to ValueError removes that distinction at the only point where it reaches the caller.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)

get_zoneinfo raises McpError(INVALID_PARAMS) so a caller can tell a bad
timezone argument from a server fault. The handler's blanket
`except Exception` catches that McpError too and re-raises it as a plain
ValueError, so the code is dropped and the message is prefixed twice:

  get_current_time(timezone="Invalid/Zone")

  before: "Error processing mcp-server-time query: Invalid timezone:
           'No time zone found with key Invalid/Zone'"
  after:  "Invalid timezone: 'No time zone found with key Invalid/Zone'"

The fetch server, the other Python server here, already lets McpError
propagate. Re-raise it before the blanket handler; everything else keeps
the existing prefix.

The handler had no tests because it was only reachable through serve(),
which binds stdio. Extract build_server() so a test can drive it over an
in-memory session; serve() is otherwise unchanged.

Adds three handler tests: the McpError path (fails without the fix), the
generic-error path, and a success path.
Copilot AI balanced review requested due to automatic review settings September 17, 2026 17:17

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

MCP SDK 1.29.0 still converts the re-raised error into an untyped tool result, so INVALID_PARAMS is not preserved.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Fixes time-server error handling and makes the server testable in memory.

Changes:

  • Re-raises McpError before generic exception wrapping.
  • Extracts build_server().
  • Adds handler-level tests.
File summaries
File Description
src/time/src/mcp_server_time/server.py Refactors server creation and error handling.
src/time/test/time_server_test.py Tests success and error paths through an in-memory session.
Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 1
  • Review effort level: Balanced

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +215 to +218
except McpError:
# Already carries a specific code and message (e.g. INVALID_PARAMS
# from get_zoneinfo); re-wrapping would discard both.
raise
CallToolResult.content is a union; only TextContent carries .text, so
accessing it directly failed pyright with 12 errors across three call
sites (ImageContent, AudioContent, ResourceLink, EmbeddedResource).

Add a first_text() helper that asserts the block is TextContent and
returns its text. Both time tools only ever emit text content, so the
assertion documents that rather than widening the tests.

Also lifts the per-test imports to module scope, matching the rest of
the file.
Copilot AI review requested due to automatic review settings September 17, 2026 20:44

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

MCP 1.29 still converts the re-raised error into an untyped tool-error result.

Review details

Suppressed comments (1)

src/time/src/mcp_server_time/server.py:218

  • Re-raising here still does not preserve INVALID_PARAMS on the wire. With the locked MCP 1.29 SDK, the wrapper installed by @server.call_tool() catches every Exception from this handler and converts it to CallToolResult(isError=True), so the outer JSON-RPC handler never receives this McpError. The new test's assert result.isError confirms that the typed protocol error is still flattened; only the duplicate prefix is removed. Please route this through a request handler/API that can emit the original ErrorData (or first fix/use an SDK API that lets McpError escape), and assert that session.call_tool(...) raises an McpError whose code is INVALID_PARAMS.
        except McpError:
            # Already carries a specific code and message (e.g. INVALID_PARAMS
            # from get_zoneinfo); re-wrapping would discard both.
            raise
  • Files reviewed: 2/2 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants