Skip to content

Relax monolith ElicitRequestURLParams.elicitation_id for 2026-07-28#2913

Merged
maxisbey merged 1 commit into
mainfrom
types-monolith-2026-followup
Jun 19, 2026
Merged

Relax monolith ElicitRequestURLParams.elicitation_id for 2026-07-28#2913
maxisbey merged 1 commit into
mainfrom
types-monolith-2026-followup

Conversation

@maxisbey

Copy link
Copy Markdown
Contributor

Follow-up to #2912, addressing the two review comments that landed after merge.

Motivation and Context

#2912 re-vendored the 2026-07-28 draft schema, which dropped elicitationId from ElicitRequestURLParams and made CancelledNotificationParams.requestId required again. The regenerated v2026_07_28 surface package picked both up correctly, but the version-superset monolith in _types.py wasn't relaxed to match. As a result parse_server_result("tools/call", "2026-07-28", ...) would reject a spec-valid InputRequiredResult carrying a URL-mode elicitation at the monolith step (the ElicitRequest arm fails for missing elicitationId).

What changed

  • src/mcp/types/_types.py: ElicitRequestURLParams.elicitation_id is now str | None = None with a version note. The v2025_11_25 surface package still requires it on the wire, so 2025 callers are unchanged. Same superset pattern CancelledNotificationParams.request_id already uses.
  • src/mcp/types/_types.py: CancelledNotificationParams.request_id docstring updated to note that 2026-07-28 makes the field required again and narrows it to client-issued requests.
  • tests/types/test_methods.py: new test pinning that a 2026 InputRequiredResult with a URL elicit (no elicitationId) parses through both steps.
  • tests/interaction/lowlevel/test_elicitation.py: capture-list type widened to list[str | None] to match the relaxed field.

The elicit_url / send_elicit_complete helper signatures in peer.py / server/session.py / server/mcpserver/context.py are intentionally not touched here; those are 2025-11-25 API and version-gating them belongs with #2898 / #2904.

How Has This Been Tested?

pyright, ruff, ./scripts/test (full suite, 100% coverage, strict-no-cover clean). The new test in tests/types/test_methods.py is the repro from the review comment.

Breaking Changes

mcp.types.ElicitRequestURLParams can now be constructed without elicitation_id. Existing constructors that pass it are unaffected.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

AI Disclaimer

…2026-07-28

Follow-up to #2912. The re-vendored 2026-07-28 schema dropped elicitationId
from ElicitRequestURLParams, but the version-superset monolith model still
required it, so parse_server_result rejected a spec-valid 2026
InputRequiredResult embedding a URL elicitation at the monolith step. The
v2025_11_25 surface still enforces requiredness for 2025 callers.

Also updates the CancelledNotificationParams.request_id docstring to note
that 2026-07-28 makes the field required again.
@maxisbey maxisbey marked this pull request as ready for review June 19, 2026 15:00
@maxisbey maxisbey merged commit 1cec2d6 into main Jun 19, 2026
32 checks passed
@maxisbey maxisbey deleted the types-monolith-2026-followup branch June 19, 2026 15:06

@claude claude Bot 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.

LGTM — small, well-tested follow-up that brings the monolith in line with the re-vendored 2026-07-28 schema using the existing superset pattern; the inline comments are a docstring nit and a pre-existing example issue, neither blocking.

Extended reasoning...

Overview

This PR relaxes ElicitRequestURLParams.elicitation_id from str to str | None = None in the version-superset monolith (src/mcp/types/_types.py), updates the CancelledNotificationParams.request_id docstring with a 2026-07-28 version note, adds a test pinning that a 2026-07-28 InputRequiredResult carrying a URL-mode elicitation without elicitationId parses through both the surface and monolith steps, and widens a capture-list type annotation in an interaction test. It is a focused follow-up to #2912, addressing review comments that landed after that merge.

Security risks

None identified. The change loosens a field on the superset model only; the v2025_11_25 surface package still requires elicitationId on the wire, so 2025-11-25 wire validation is unchanged. All SDK-side producers (elicit_url, send_elicit_complete, the elicitation helpers in server/session.py, shared/peer.py, server/mcpserver/context.py) still take a required str and pass it explicitly, so no None can flow out from the typed API.

Level of scrutiny

Moderate-to-low. Although protocol type definitions are shared infrastructure, this change follows the exact pattern already established for CancelledNotificationParams.request_id (required on some wire versions, optional in the monolith superset), is spec-driven (the 2026-07-28 schema dropped the field), and is covered by a new test that reproduces the original review-comment failure. The PR description accurately matches the diff, and the deliberately-deferred helper-signature work is called out with issue references.

Other factors

The two inline findings are a pre-existing example-snippet attribute lookup issue (not introduced here) and a docstring-wording nit about the unqualified same-direction sentence; neither affects runtime behavior or blocks merging. The author reports the full suite, pyright, and ruff passing with 100% coverage.

Comment thread src/mcp/types/_types.py
Comment on lines 1868 to 1876
The ID of the request to cancel.

This MUST correspond to the ID of a request previously issued in the same direction.
Required on the wire through 2025-06-18; optional from 2025-11-25.
Required on the wire through 2025-06-18; optional at 2025-11-25; required again from
2026-07-28, where it must name a request the client previously issued (servers send
this notification only to terminate a `subscriptions/listen` stream).
"""
reason: str | None = None
"""An optional string describing the reason for the cancellation."""

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.

🟡 The unchanged sentence "This MUST correspond to the ID of a request previously issued in the same direction" now contradicts the new 2026-07-28 clause added directly below it, which says the field must name a request the client previously issued (a server-sent cancellation terminating a subscriptions/listen stream names a request issued in the opposite direction). Consider qualifying the same-direction sentence as applying through 2025-11-25, matching the vendored v2026_07_28 surface which drops the same-direction wording entirely.

Extended reasoning...

The inconsistency. The PR extends the request_id docstring on CancelledNotificationParams (src/mcp/types/_types.py:1868-1876) with a 2026-07-28 version note: the field is "required again from 2026-07-28, where it must name a request the client previously issued (servers send this notification only to terminate a subscriptions/listen stream)". The sentence directly above it, untouched by the PR, still states without qualification: "This MUST correspond to the ID of a request previously issued in the same direction." Within the same docstring these two MUST-level statements now disagree for the exact case the new sentence describes.

Why they conflict. Through 2025-11-25, notifications/cancelled may only target a request the sender issued — each side cancels its own requests, so "same direction" is accurate. At 2026-07-28 the rule changes: the spec narrows the notification so that a server-sent notifications/cancelled names the client's subscriptions/listen request to terminate that stream. From the server's (sender's) perspective, that is a request issued in the opposite direction. The vendored 2026-07-28 surface confirms this: src/mcp/types/v2026_07_28/__init__.py (CancelledNotificationParams.request_id, around lines 2177-2189) drops the same-direction wording entirely and reads "This MUST correspond to the ID of a request the client previously issued", and its ClientNotification docstring describes the server-sent listen-termination case explicitly.

Concrete walk-through. On a 2026-07-28 session: (1) the client sends subscriptions/listen with id 42; (2) the server later decides to close the stream and sends notifications/cancelled with requestId: 42. Per the new sentence in this docstring (and the vendored schema), this is the only legitimate server-sent cancellation at 2026-07-28 — yet it violates the unqualified first sentence, since request 42 was issued client→server while the notification travels server→client. A reader applying the first sentence literally would conclude this spec-mandated message is malformed.

Why this matters (and why it doesn't matter much). This is the version-superset monolith whose per-field docstrings are the documented mechanism for recording version availability (see the module docstring), so internal consistency of those notes is the point of the file. That said, the impact is purely documentation-level: no validation logic encodes the same-direction rule, and the new clause does spell out the correct 2026 behavior, so a careful reader is unlikely to be misled — which is essentially the refuting reviewer's point. The refutation is fair that a reader gets the right answer for 2026 from the adjacent clause; the residual problem is that the first sentence is phrased as an unqualified MUST that the very next sentence contradicts, rather than as a general rule with a scoped exception. Since the PR is already touching this docstring, tightening it is nearly free.

Suggested fix. Qualify the first sentence, e.g.: "This MUST correspond to the ID of a request previously issued in the same direction (through 2025-11-25)." — or fold it into the version notes: "Through 2025-11-25 this MUST correspond to the ID of a request previously issued in the same direction. Required on the wire through 2025-06-18; optional at 2025-11-25; required again from 2026-07-28, where it must name a request the client previously issued ...". Either keeps the superset docstring accurate for every version it covers.

Comment thread src/mcp/types/_types.py
Comment on lines 1958 to 1968
url: str
"""The URL that the user should navigate to."""

elicitation_id: str
elicitation_id: str | None = None
"""The ID of the elicitation, which must be unique within the context of the server.

The client MUST treat this ID as an opaque value.
The client MUST treat this ID as an opaque value. Required on the wire at
2025-11-25; removed at 2026-07-28.
"""

task: TaskMetadata | None = None

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.

🟣 Pre-existing issue (not introduced by this PR, but adjacent to the field being relaxed): examples/snippets/clients/url_elicitation_client.py:73 reads getattr(params, "elicitationId", None), but pydantic only exposes the snake_case attribute elicitation_id, so the example always prints Elicitation ID: None even when the server supplied one. Now that elicitation_id can also legitimately be None at 2026-07-28, the example should read params.elicitation_id and handle a real None explicitly.

Extended reasoning...

The bug. examples/snippets/clients/url_elicitation_client.py:73 extracts the elicitation id with elicitation_id = getattr(params, "elicitationId", None). The params object delivered to the client elicitation callback is the monolith mcp.types.ElicitRequestURLParams, a pydantic v2 model whose MCPModel config sets alias_generator=to_camel and populate_by_name=True. In pydantic v2, the camelCase alias is a wire/validation name only — it is never exposed as an instance attribute. The only attribute is the Python field name elicitation_id, so the getattr lookup for "elicitationId" always misses and falls through to its None default.

Code path / proof. Walk through the URL elicitation flow with this example client:

  1. The server calls session.elicit_url("Authorize…", "https://example.com/oauth", "auth-001"), which sends elicitation/create with wire params {"mode": "url", "url": "…", "elicitationId": "auth-001", …}.
  2. The client session validates those params into ElicitRequestURLParams; the wire key elicitationId populates the model field elicitation_id (value "auth-001"). After validation the instance has the attribute params.elicitation_id == "auth-001" — and no attribute named elicitationId.
  3. Line 73: getattr(params, "elicitationId", None) → attribute does not exist → returns None.
  4. Line 98: print(f"\n Elicitation ID: {elicitation_id}") → always prints Elicitation ID: None, even though the server supplied auth-001.

Note line 72 right above it (url = getattr(params, "url", None)) works only because url has no snake/camel divergence; line 73 is simply the wrong attribute name, not intentional defensiveness.

Why nothing catches it. The example is a snippet, not exercised by the test suite for this output, and getattr with a default silently swallows the missing attribute, so there is no error — just a permanently wrong display value in a security-warning prompt that is supposed to show the user what they are consenting to.

Why it is worth mentioning on this PR. This PR relaxes ElicitRequestURLParams.elicitation_id to str | None so the monolith superset accepts 2026-07-28 bodies that omit elicitationId. After this change the correct attribute can also legitimately be None, so the example's unconditional Elicitation ID: None becomes ambiguous: a reader can no longer tell whether the id was genuinely absent (2026-07-28) or just dropped by the wrong attribute lookup (2025-11-25).

Fix. Replace line 73 with elicitation_id = params.elicitation_id if isinstance(params, types.ElicitRequestURLParams) else None (or simply getattr(params, "elicitation_id", None) to keep the existing defensive style), and ideally only print the Elicitation ID line when the value is not None. This is display-only in an example client and not introduced by this diff, so it should not block the PR.

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