Skip to content

Server-side 2026-07-28 stateless support: classifier, driver split, server/discover#2928

Open
maxisbey wants to merge 16 commits into
mainfrom
s2-server-stateless-green
Open

Server-side 2026-07-28 stateless support: classifier, driver split, server/discover#2928
maxisbey wants to merge 16 commits into
mainfrom
s2-server-stateless-green

Conversation

@maxisbey

@maxisbey maxisbey commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Implements server-side support for the 2026-07-28 stateless streamable-HTTP path, removing server-stateless from the conformance expected-failures baseline (25/25 checks pass).

Part of #2891. Touches #2892, #2893, #2895, #2896.

Motivation and Context

The 2026-07-28 protocol revision drops the initialize handshake on streamable HTTP: each POST is self-describing (protocol version, client info, client capabilities ride in params._meta) and the server responds with a single JSON-RPC reply. Getting there cleanly meant separating "what handles a request" from "what owns the connection lifecycle" — the existing code threaded a stateless: bool flag through several layers and re-derived per-connection facts in multiple places.

What changed

Kernel/driver split. ServerRunner is now a pure handler kernel (server, connection, lifespan_stateon_request/on_notify). Three free-function drivers compose it with a transport: serve_one (single exchange), serve_connection (caller-supplied dispatcher), serve_loop (stream-pair convenience; what Server.run() and the stateful HTTP path now call).

Connection owns per-peer state. Two factories — from_envelope for the per-request stateless path, for_loop for handshake-driven connections. protocol_version: str is non-Optional and factory-populated; the previous or "2025-11-25" fallbacks and the 17-line resolver are gone. _NoChannelOutbound makes "no back-channel" a property of the held object, not a mode flag — stateless: bool is removed from ServerRunner, ServerSession, and Server.run().

Inbound classifier. shared/inbound.py is a pure validation ladder (envelope shape → header/body match → version supported) returning a typed verdict; ERROR_CODE_HTTP_STATUS is the single code→status table the HTTP entry reads.

Modern HTTP entry. _streamable_http_modern.handle_modern_request is the per-POST path: parse → classify → Connection.from_envelopeserve_one → status-mapped response. The session manager routes by header: known initialize-handshake versions go to the legacy transport; everything else (including unrecognised versions) goes to the modern entry where the classifier validates.

Lifespan owned once. StreamableHTTPSessionManager.run() enters app.lifespan once at startup for both stateful and stateless modes; the stateful path now drives via serve_loop with that state instead of re-entering per session.

server/discover. Auto-derived from registered handlers; a 2025 client gets METHOD_NOT_FOUND from the method/version map, not from a version check in the handler body.

@mcp.tool() exception handling. MCPError (and subclasses) raised from a tool body now propagates as a top-level JSON-RPC error instead of being wrapped as isError: true content — generalising the existing special-case for UrlElicitationRequiredError.

Client. The streamable-HTTP client now parses JSON-RPC error bodies on 4xx responses (so the typed error reaches the caller instead of a generic HTTP exception).

How Has This Been Tested?

  • server-stateless conformance scenario: 25/25 at the pinned harness, removed from both expected-failures baselines
  • ./scripts/test: 100% branch coverage, strict-no-cover clean
  • tests/shared/test_inbound.py covers the classifier exhaustively; tests/interaction/transports/test_hosting_http_modern.py pins the wire contract end to end
  • Runtime-driven: modern + legacy paths exercised via the in-process ASGI fixture (405 verb mismatch, parse/invalid-request split, header mismatch, unsupported version, handler exception, exit-stack cleanup raise/hang)

Breaking Changes

All documented in docs/migration.md:

  • Server.run() no longer takes stateless: bool
  • StatelessModeNotSupported removed → callers catch NoBackChannelError
  • Streamable HTTP lifespan is entered once at manager startup (was per-session/per-request)
  • ServerSession constructor signature changed (now (request_outbound, connection))
  • MCPError raised from @mcp.tool() now surfaces as a protocol error (was isError: true)

Types of changes

  • 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

Additional context

Method-existence is intentionally not a classifier rung — kernel dispatch owns that decision so custom-registered methods route and the answer lives in one place. The modern entry takes the spec's "cannot accept" branch for notification POSTs (400 + INVALID_REQUEST); the core protocol defines no client→server notifications over HTTP at this revision (cancellation is SSE-stream close).

AI Disclaimer

@maxisbey maxisbey force-pushed the s2-server-stateless-green branch 2 times, most recently from 9c80936 to 732ebc4 Compare June 20, 2026 17:15
maxisbey added 11 commits June 20, 2026 18:40
…xture tool, migration note

- F1: conformance harness pin -> github:modelcontextprotocol/conformance#fe3a3103;
  reconcile both expected-failures baselines (auth/scope-step-up moves to 2026-only per #337)
- D2: extract to_jsonrpc_response + aclose_shielded helpers in runner.py;
  add handler_exception_to_error_data in shared/jsonrpc_dispatcher.py (single exception->ErrorData source)
- H1: new src/mcp/shared/inbound.py — classify_inbound_request() + ERROR_CODE_HTTP_STATUS table
  (pure classifier; no I/O, no mcp.server imports; method-at-version via CLIENT_REQUESTS map)
- H2: server/discover auto-derived handler at lowlevel.Server.__init__;
  get_capabilities() params now optional; new server_info property
- H4: test_missing_capability fixture tool in everything-server (raises MISSING_REQUIRED_CLIENT_CAPABILITY)
- C1: docs/migration.md entry for stateless_http lifespan cardinality change

Part of the 2026-07-28 server-stateless conformance work (#2891).
…ifier tests

ServerRunner becomes the handler kernel only:
- ServerRunner(server, connection, lifespan_state, *, dispatch_middleware) — no run(),
  no dispatcher field, no stateless flag, no __post_init__ Connection construction
- on_request/on_notify as cached_property (middleware composed once)
- _resolve_protocol_version deleted; kernel reads connection.protocol_version as a fact
- serve_connection(server, dispatcher, *, connection, lifespan_state, init_options) and
  serve_one(server, request, *, connection, dctx, lifespan_state) free-function drivers;
  both aclose_shielded(connection) in finally; neither constructs Connection

Connection factories replace post-construction mutation:
- from_envelope(pv, client_info, caps, *, outbound=_NO_CHANNEL) — born-ready, initialized set
- for_loop(outbound, *, session_id, protocol_version_hint) — handshake-driven, version seeded
- protocol_version: str non-Optional; has_standalone_channel derived (outbound is not _NO_CHANNEL)
- _NoChannelOutbound private singleton: send_raw_request raises NoBackChannelError, notify drops

ServerSession two-channel selector (closes the related-request-id routing on stateful HTTP):
- ServerSession(request_outbound, connection, *, standalone_outbound) per request in _make_context
- send_request/send_notification select channel by related_request_id presence
- StatelessModeNotSupported + four if-stateless guards deleted (subsumed by _NoChannelOutbound)
- Both type:ignore[call-arg] removed; nothing transport-specific crosses the Outbound Protocol

Also:
- streamable_http.py: ServerMessageMetadata(protocol_version=...) writers deleted (no readers)
- tests/shared/test_inbound.py: 32 table-driven classifier tests, 100% branch coverage
- tests/server/test_runner.py: resolver tests deleted, fixture migrated to factories (partial;
  full migration in T2)

Tree is intentionally mid-reshape: lowlevel.Server.run() and the modern HTTP entry still call
the deleted ServerRunner.run() / SingleExchangeDispatcher; wave 4 (D5/H3) rewrites those.

Part of #2891.
Modern HTTP entry (_streamable_http_modern.py):
- handle_modern_request: classify_inbound_request -> on rejection write JSONRPCError at
  ERROR_CODE_HTTP_STATUS-mapped status; on route -> Connection.from_envelope -> serve_one
- All status codes via ERROR_CODE_HTTP_STATUS table (PARSE_ERROR added: 400)
- id:null preserved on parse-error responses (exclude_none would drop it)
- SingleExchangeDispatcher deleted (-73 lines); duplicate shielded-teardown deleted;
  both reportPrivateUsage ignores deleted; per-request lifespan entry deleted

StreamableHTTPSessionManager:
- run() enters lifespan once at startup; lifespan_state stored on self
- _handle_stateless_request rewired: builds JSONRPCDispatcher (can_send_request=False so
  the per-request channel raises NoBackChannelError for server->client requests, allows
  notifications) + Connection.from_envelope(header-seeded version) -> serve_connection.
  Bypasses Server.run(); stateless kwarg removed from both manager call sites.

lowlevel.Server.run():
- Body rewritten: enter lifespan -> JSONRPCDispatcher -> Connection.for_loop ->
  serve_connection. stateless kwarg deleted (no callers). Net -4 LoC.

Test migrations:
- test_runner.py: connected_runner uses factories; resolver tests deleted; per-request
  session tests; serve_one/serve_connection/to_jsonrpc_response/aclose_shielded coverage
- test_connection.py: factory tests; check_capability via from_envelope
- test_session.py: StubOutbound; two-channel selector tests (4 new)
- test_stateless_mode.py: rewritten; 8x pytest.raises(NoBackChannelError) via _NoChannelOutbound
- test_streamable_http_modern.py: classifier-path tests; SingleExchangeDispatcher tests deleted
- test_lowlevel_exception_handling.py: stateless=True kwarg removed
- test_streamable_http.py:1371: protocol_version assertion updated (non-Optional)

Part of #2891.
…odies

A spec-correct server may return the JSON-RPC error in an application/json body
at a non-2xx status (e.g. 400 for INVALID_PARAMS, 404 for METHOD_NOT_FOUND per
the 2026-07-28 transport binding). Surface that error to the caller instead of
synthesizing a generic INTERNAL_ERROR / 'Session terminated' from the status
alone. Falls back to the status-derived error if the body isn't valid JSON-RPC.

Unconditional (not version-gated): parsing a JSON-RPC body from a 4xx
application/json response is correct at any protocol version.
…ts, assertion patterns

Fixes from the testing-standards review of the new/modified test files.

Hollow-proof (the two SERIOUS findings):
- test_standalone_outbound_defaults_to_request_outbound: drop outbound= from from_envelope so
  conn.outbound is the no-channel sentinel; traffic on the request outbound now proves the default
- test_server_info_version_falls_back_to_package: assert == importlib.metadata.version('mcp')

Assertion patterns:
- test_stateless_mode.py: 8x match= -> exc.value.method (structured attribute, not message text)
- test_streamable_http_modern.py: literal 404/400 status asserts (impl reads same table, so
  table[X]==table[X] proved nothing); restore full-object json equality with id:None pin
- test_inbound.py: assert isinstance(route, InboundModernRoute) before frozen check

Constants: replace version/header/meta-key string literals with named imports across
test_session.py / test_streamable_http_modern.py.

Hygiene: drop from __future__ import annotations from tests/shared/test_inbound.py and
src/mcp/shared/inbound.py (no forward refs); duplicate StubOutbound locally in
test_stateless_mode.py (no cross-file test imports); _StubDispatchContext methods raise
NotImplementedError; private _request_handlers -> public get_request_handler.

Docstrings: add Spec-mandated:/SDK-defined: provenance tags to ~50 tests across 7 files.

Naming: test_cacheable_defaults -> test_discover_result_defaults_to_immediately_stale_private_cache;
test_header_rung_skipped_when_headers_none -> test_header_rung_does_not_reject_when_headers_arg_is_none;
drop 'handler' from test_serve_one_maps_error_... (it's kernel METHOD_NOT_FOUND).
After rebasing onto #2926:
- Re-add # pyright: ignore[reportDeprecated] on the deprecated method calls in the
  reworked test_connection.py / test_server_context.py / test_stateless_mode.py
  (the rewrites had dropped main's suppressions)

Fix to the client 4xx body-parsing patch (bb9b134):
- A server's 4xx JSON-RPC error body may carry id:null (request rejected before its
  id was parsed). Surfacing that verbatim broke response correlation (client waits
  for id=N, gets id=null, hangs). Now use the parsed error data with the client's
  own request id.
- Only surface the body when it's a JSONRPCError; anything else falls through to
  the status-derived stand-in.
- Relax the two 'Session terminated' message-text assertions in test_streamable_http.py
  (the client now surfaces the server's actual message).
…rror

The tool wrapper at Tool.run() was catching MCPError along with all other
exceptions and re-wrapping it as ToolError, which _handle_call_tool then
flattens into CallToolResult(isError=True). This discards the JSON-RPC
error code and structured data, so a tool raising e.g.
MISSING_REQUIRED_CLIENT_CAPABILITY couldn't produce a protocol-level
error response.

UrlElicitationRequiredError was already special-cased; since it subclasses
MCPError, generalising the re-raise to MCPError covers both and any future
protocol-error subclass.
- T3 verifier: snapshot supportedVersions instead of asserting equal to the
  constant that produced it (was tautological)
- T5 verifier: drop near-duplicate middleware-returns-42 test (zero coverage gain)
- 3 tests updated for the MCPError-re-raise change: tools that propagate an
  uncaught MCPError from a peer call (no roots/sampling callback) or from the
  transport (NoBackChannelError on stateless HTTP) now surface it as a
  top-level JSON-RPC error rather than CallToolResult(isError=True)
@maxisbey maxisbey force-pushed the s2-server-stateless-green branch from 732ebc4 to 4a79c32 Compare June 20, 2026 19:58
maxisbey added 2 commits June 20, 2026 20:16
… header-mismatch ahead of version-supported

The conformance harness sends header=v999.0.0 (matching the body) to trigger
UnsupportedProtocolVersionError, and header=2026 with body=v999 to trigger
HeaderMismatch. With the manager only routing on header in MODERN_PROTOCOL_VERSIONS,
the first case fell through to the legacy stateful path and never reached the
classifier; with the classifier checking version-supported before header-match,
the second case returned -32022 instead of -32020.

Manager now routes any non-legacy header value to the modern entry (the
classifier owns rejection of unknown versions). Classifier now checks
header-body agreement before version-supported, so a client that disagrees
with itself is told so rather than told its body version is unsupported.

Also: 3 dead test-helper lines (registered-but-never-invoked handler bodies
and a never-read property on a stub) replaced per the testing-standards
convention.
…anager era-routing)

The manager only routes header values in SUPPORTED_PROTOCOL_VERSIONS (or no
header) to the legacy transport, so _validate_protocol_version's error branch
was unreachable. Deleted the function; _validate_request_headers now just
delegates to _validate_session.

Updated the one shared/ test that exercised the dead branch to assert the new
modern-entry rejection instead.
@maxisbey maxisbey force-pushed the s2-server-stateless-green branch from 644745c to c94095c Compare June 20, 2026 20:22
…or audit

Conformance @ 65fcd39: server-stateless 25/25 SUCCESS, 0 FAILURE. Removed from
both expected-failures files along with two stale entries that now pass
(input-required-result-validate-input, input-required-result-missing-input-response).
http-header-validation stays baselined (SEP-2243 Mcp-Method/Mcp-Name cross-check
is separate work).

TODO comments in S2-touched files now cite their CLEANUP-LEDGER row;
_requirements.py drops the deleted SingleExchangeDispatcher class name from
its prose constant.
@maxisbey maxisbey force-pushed the s2-server-stateless-green branch from c94095c to 2313cde Compare June 20, 2026 20:28
@maxisbey maxisbey marked this pull request as ready for review June 20, 2026 20:56
Comment thread src/mcp/server/_streamable_http_modern.py
Comment thread docs/migration.md Outdated
…igration.md fixes

Four review findings, reworked for by-construction:

1. Non-POST -> bare 405 + Allow header (HTTP-layer rejection happens before
   JSON-RPC parsing, so it doesn't go through the JSON-RPC status table).
   _write stays table-pure with no override parameter.

2. Stateful HTTP no longer enters lifespan twice. New serve_loop() free
   function in runner.py owns the loop-mode dispatcher recipe (notably
   inline_methods={'initialize'}); both Server.run() and the manager's
   stateful path call it. Manager owns lifespan for all HTTP modes;
   Server.run() owns it for stdio/memory. Stateful sessions also now have
   their session_id threaded onto Connection.

3. Well-formed JSON that isn't a single request object (notification,
   response, batch) returns INVALID_REQUEST instead of PARSE_ERROR.
   json.loads and model_validate are now distinct steps; INVALID_REQUEST
   added to ERROR_CODE_HTTP_STATUS. The classifier now receives the decoded
   body directly rather than a reconstructed dict.

4. migration.md: fixed the broken ctx.connection example (replaced with
   prose; the high-level Context surface for this is being finalised),
   documented stateless kwarg + StatelessModeNotSupported removals,
   updated the ServerSession constructor example, and extended the
   lifespan-once entry to cover stateful as well.
Comment on lines +397 to +413
async def _handle_discover(
self, ctx: ServerRequestContext[LifespanResultT], params: types.RequestParams | None
) -> types.DiscoverResult:
"""Default `server/discover` handler.

Auto-derived from server state at call time, so capabilities reflect
whatever has been registered (constructor `on_*` kwargs and later
`add_request_handler` calls). Operators can replace it wholesale via
`add_request_handler("server/discover", ...)`. Reachability for legacy
peers is decided at the boundary (`types.methods`), not here.
"""
return types.DiscoverResult(
supported_versions=list(MODERN_PROTOCOL_VERSIONS),
capabilities=self.get_capabilities(),
server_info=self.server_info,
instructions=self.instructions,
)

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 auto-derived server/discover handler builds its capabilities via self.get_capabilities() with no arguments, so a lowlevel-Server operator who declares listChanged or experimental capabilities via create_initialization_options(NotificationOptions(...), {...}) will advertise them in the initialize response but not in the server/discover response. Consider threading the stored options through (or storing them on the Server), or explicitly documenting that discover ignores create_initialization_options arguments.

Extended reasoning...

What the bug is. The new default Server._handle_discover (src/mcp/server/lowlevel/server.py:397-413) returns DiscoverResult(capabilities=self.get_capabilities(), ...). With this PR, get_capabilities() defaults notification_options to NotificationOptions() (all list_changed flags False) and experimental_capabilities to None. Meanwhile the legacy initialize path still reports capabilities from InitializationOptions (ServerRunner._handle_initialize uses opts.capabilities), which the operator builds via the documented create_initialization_options(NotificationOptions(tools_changed=True), {\"my-ext\": {...}}). The same server therefore advertises different capabilities depending on which protocol era the client speaks.

Code path / step-by-step proof.

  1. An operator constructs a lowlevel Server and calls server.run(read, write, server.create_initialization_options(NotificationOptions(tools_changed=True), {\"my-ext\": {}})) — the existing, documented way to declare listChanged and experimental capabilities.
  2. A 2025-era client sends initialize; _handle_initialize returns opts.capabilitiestools.listChanged == true and the experimental block is present.
  3. A 2026-era client (or any modern-envelope path) sends server/discover; _handle_discover calls self.get_capabilities() with no arguments → notification_options = NotificationOptions(), experimental_capabilities = None → the response reports tools.listChanged == false and no experimental block.
  4. The two advertisements disagree for the same server, and there is no knob to influence the discover-derived capabilities short of replacing the whole handler via add_request_handler(\"server/discover\", ...).

Why existing code doesn't prevent it. _handle_discover structurally cannot see the per-run InitializationOptions — they are a parameter of Server.run()/serve_loop, not server state — so the under-advertisement is baked into the handler's design rather than an accidental wiring miss.

Mitigating factors (why this is filed as a nit, addressing the refutation). The practical blast radius is narrow: every in-tree caller (MCPServer, the SSE app, the stdio __main__, the streamable-HTTP manager) calls create_initialization_options() with no arguments, so initialize and discover agree on all SDK-owned paths; only out-of-tree lowlevel operators who explicitly pass NotificationOptions/experimental are affected. The affected fields are advisory (listChanged hints, experimental block), and on the modern single-exchange HTTP path server notifications are currently dropped anyway (TODO D-005a), so listChanged=False there is arguably the more honest answer. The pre-existing TODO(L53) on get_capabilities already flags that list_changed/experimental should be derived from server state instead of caller-assembled options, and the discover docstring documents the wholesale-override escape hatch. None of that makes the divergence go away, though — the inconsistency is newly introduced by this PR (server/discover is new here) and is user-visible to any client that compares the two advertisements.

Impact. A 2026-era client consuming server/discover from such a server will believe list-changed notifications are not emitted and that no experimental capabilities exist, while a 2025-era client connecting to the same server is told the opposite. Capability-gated client behaviour (subscribing to list-changed, enabling experimental features) silently degrades on the modern path.

How to fix. Either (a) let the Server store NotificationOptions / experimental capabilities (e.g. constructor params, in line with TODO(L53)) and have both create_initialization_options() and _handle_discover read from that single source, or (b) at minimum document on create_initialization_options / the migration guide that arguments passed there do not influence the server/discover response and that operators needing parity must override the handler via add_request_handler(\"server/discover\", ...).

…l from Connection; hygiene

- ServerSession: drop standalone_outbound kwarg; read connection.outbound at the
  channel-select sites (one source of truth for the standalone channel; the
  documented two-arg form is now correct rather than mis-wiring)
- serve_loop: add to __all__; list in module docstring; soften the
  recipe-ownership claim
- _write: drop dead extra_headers kwarg
- _SingleExchangeDispatchContext.can_send_request: field(init=False) so True is
  unconstructible
- inbound: use UnsupportedProtocolVersionErrorData typed model for -32022 data;
  document why INTERNAL_ERROR is unmapped; anchor the header-validation TODO
- Sharpen the notification-POST rejection comment to cite the spec's
  cannot-accept branch
- Docstring corrections: session.py module docstring (per-request, not
  per-connection); Server.run() drops the internal tracking ref
- TODO anchors aligned to current tracking entries
- Tests: use public client_params property; complete ERROR_CODE_HTTP_STATUS
  parametrize; assert .error.code over message-text regex
Comment on lines 39 to +42
with anyio.fail_after(5):
# stateless=True so server.run doesn't wait for initialize handshake.
# Before the fix, this raised ExceptionGroup(ClosedResourceError).
await server.run(read_recv, write_send, server.create_initialization_options(), stateless=True)
await server.run(read_recv, write_send, server.create_initialization_options())

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.

🟡 Stale comment: this PR removed the stateless kwarg from Server.run() (and updated this call), but the line above still says "stateless=True so server.run doesn't wait for initialize handshake" — it now describes an argument that no longer exists. Update or delete the comment.

Extended reasoning...

What the issue is. This PR deletes the stateless: bool parameter from the lowlevel Server.run() (see src/mcp/server/lowlevel/server.py and the new migration-guide entry "Server.run() no longer takes a stateless flag"), and the call site in tests/server/test_lowlevel_exception_handling.py was mechanically updated to await server.run(read_recv, write_send, server.create_initialization_options()). The comment immediately above that call, however, was left untouched: # stateless=True so server.run doesn't wait for initialize handshake. The comment now refers to an argument that is neither passed nor accepted anywhere in the API.

Why it's misleading. The comment doesn't just name a removed flag — its rationale no longer describes how the test works. The test pushes a RuntimeError into the read stream and then closes it; server.run() exits because the read side closes, not because an init gate was skipped. No request is ever dispatched, so the initialize handshake is irrelevant to the test's flow either way. A reader trying to understand the new serve_loop-based Server.run() could be misled into believing a stateless mode still exists on the loop driver, when the PR's whole point is that statelessness is now a property of how the Connection is constructed (Connection.from_envelope), not a flag on the loop.

Step-by-step. (1) Before this PR, line 42 read await server.run(..., stateless=True) and the comment on line 40 explained that argument. (2) This PR removes stateless from Server.run()'s signature and edits line 42 to drop the kwarg. (3) Line 40 still says "stateless=True so server.run doesn't wait for initialize handshake", describing code that no longer exists. (4) The test still passes — the exit path is the read-stream close — confirming the comment's rationale is not load-bearing.

Why nothing else catches it. Comments aren't checked by the type checker or tests, and nothing else in the file references the removed flag, so the stale text will persist until someone notices it manually.

How to fix. Delete the line, or replace it with something accurate, e.g. # server.run exits when the read stream yields the exception and then closes. One-line cleanup; behaviour is unaffected, so this is a nit and shouldn't 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