Skip to content

Commit e74eee3

Browse files
committed
Remove the Posture setting; always serve both protocol eras
Delete the Posture enum, its export, and the posture= constructor parameter on Server and MCPServer. The lowlevel Server is a registry of handlers and carries no serving configuration; a server offers both the 2025 handshake era and the 2026 per-request era on every transport, and each stream connection's era is decided by the client's opening message, as the default already did. _StreamConnection no longer takes a starting era; it starts undecided and pins on the first era-distinctive message. The streamable-HTTP manager's header routing is unchanged: streams it has already routed to the handshake era are entered born-legacy through the transport- internal serve_legacy_stream, so the legacy leg keeps refusing enveloped requests with -32600 exactly as before. The MODERN_ONLY / LEGACY_ONLY tests and doc sections go with it, and the streamable-HTTP orphan test file is renamed now that its posture half is gone.
1 parent 4ae5d5a commit e74eee3

18 files changed

Lines changed: 59 additions & 261 deletions

docs/advanced/low-level-server.md

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -223,10 +223,7 @@ async with server.lifespan() as state, anyio.create_task_group() as tg:
223223

224224
The lifespan is entered **outside** the task group on purpose: on the way out the task group joins the still-running connection tasks first and only then does the lifespan tear down, so the shared pool outlives every connection using it (the other order tears the pool down while connections are still being served).
225225

226-
Two things about the shared-`Server` shape that the single-connection rungs never surface:
227-
228-
* Which protocol eras a server offers is `Server(posture=...)` (`Posture.DUAL` by default, `Posture.MODERN_ONLY`, `Posture.LEGACY_ONLY`); it rides along on the server object, so none of these drivers takes a posture argument you could forget.
229-
* The cross-connection listen behaviour called out under `serve_listener` above applies to any shared `Server`, including this rung; the per-connection-`Server` recipe there is how you sidestep it.
226+
One thing about the shared-`Server` shape that the single-connection rungs never surface: the cross-connection listen behaviour called out under `serve_listener` above applies to any shared `Server`, including this rung; the per-connection-`Server` recipe there is how you sidestep it.
230227

231228
An `MCPServer` reaches every one of these through `mcp.lowlevel_server`, and `mcp.close_subscriptions()` (or `Server.close_subscriptions()` down here) ends the open listen streams gracefully from the server's side.
232229

docs/migration.md

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1363,17 +1363,7 @@ The concrete dispatcher's `inline_methods=` constructor keyword is also gone: th
13631363

13641364
`server/discover` no longer pins the connection to the modern era: it is a probe, answered with modern semantics, and the era is decided by the first non-probe message. A client whose discover probe was answered can therefore still fall back to `initialize` on the same connection, and `subscriptions/listen` is now served over stdio and every other duplex stream (previously refused with `-32601`).
13651365

1366-
A stray leading notification (for example a client's `notifications/roots/list_changed`) no longer decides the era: only `initialize` (or a bare pre-handshake request) opens the legacy era, only `notifications/initialized` opens it among notifications, and any other early notification is ignored, so a 2026 client is never pinned to the legacy era by a courtesy frame it sent first. A legacy-committed connection also now refuses a request that carries the modern envelope with `-32600` instead of processing it under legacy semantics, and `initialize` is answered `-32022` (naming the modern versions the server serves) whenever it reaches the modern side, on every transport.
1367-
1368-
The new `posture` constructor parameter on `Server` and `MCPServer` narrows which eras a server offers, on stream transports and streamable HTTP alike:
1369-
1370-
```python
1371-
from mcp.server import Posture, Server
1372-
1373-
server = Server("app", posture=Posture.MODERN_ONLY) # or Posture.LEGACY_ONLY; Posture.DUAL is the default
1374-
```
1375-
1376-
A `MODERN_ONLY` server answers a 2025 handshake with `-32022` and its supported versions (on stdio and streamable HTTP); a `LEGACY_ONLY` server refuses envelope-bearing requests in legacy vocabulary and never enters the modern era. Posture is the one place a server restricts its eras; the walkthrough is in [Serving one era only](run/legacy-clients.md#serving-one-era-only).
1366+
A stray leading notification (for example a client's `notifications/roots/list_changed`) no longer decides the era: only `initialize` (or a bare pre-handshake request) opens the legacy era, only `notifications/initialized` opens it among notifications, and any other early notification is ignored, so a 2026 client is never pinned to the legacy era by a courtesy frame it sent first. A legacy-committed connection also now refuses a request that carries the modern envelope with `-32600` instead of processing it under legacy semantics, and `initialize` is answered `-32022` (naming the modern versions the server serves) whenever it reaches the modern side, on every transport. Every server offers both eras on every transport; there is no era switch on `Server` or `MCPServer`.
13771367

13781368
At stdin EOF a stdio server now winds down gracefully: it gives in-flight requests a short bounded window to finish, ends every open `subscriptions/listen` stream with its empty `SubscriptionsListenResult` (the spec's graceful closure), and then writes nothing further onto a stdout nobody reads. In particular, an in-flight request no longer receives a `CONNECTION_CLOSED` error at EOF, since the peer that would read it is gone.
13791369

docs/run/legacy-clients.md

Lines changed: 6 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,11 @@ The SDK routes every request by its `MCP-Protocol-Version` header. A request nam
99
So a legacy client is not something you build *for*. It is something that connects *to* the server you already wrote. You configure nothing.
1010

1111
!!! note
12-
Nothing on the transport, literally. There is no `legacy=` option and no version
13-
allowlist on `streamable_http_app()`, `run()`, or the session manager; by default both
14-
eras are always on, and the version header routes each request to the right one. The one
15-
place a server can restrict eras is its own posture:
16-
`MCPServer(name, posture=Posture.LEGACY_ONLY)` (or `MODERN_ONLY`) is a constructor
17-
property every transport honours (see **[Serving one era only](#serving-one-era-only)**),
18-
and the default `DUAL` is this page's assumption. The nearest thing to a per-request
19-
switch in the HTTP signature is `stateless_http`, and it is most of this page.
12+
Nothing on the transport, and nothing on the server either. There is no `legacy=` option
13+
and no version allowlist on `MCPServer`, `Server`, `streamable_http_app()`, `run()`, or the
14+
session manager; both eras are always on, and the version header routes each request to
15+
the right one. The nearest thing to a per-request switch in the HTTP signature is
16+
`stateless_http`, and it is most of this page.
2017

2118
## One handler, both eras
2219

@@ -114,25 +111,9 @@ Over HTTP, neither call reaches the other era's clients. To tell everyone, call
114111

115112
Two lines, no `if`, no version check, and you are done. That is the entire list of things a handler does differently because a legacy client exists.
116113

117-
## Serving one era only
118-
119-
Everything above assumes the default: your server offers both eras. That default is a constructor property called the server's **posture**, and it is the one place you narrow it:
120-
121-
```python
122-
from mcp.server import Posture
123-
from mcp.server.mcpserver import MCPServer
124-
125-
mcp = MCPServer("Bookshop", posture=Posture.MODERN_ONLY)
126-
```
127-
128-
`Posture.DUAL` is the default and the whole rest of this page. `Posture.MODERN_ONLY` retires the handshake era: a pre-2026 client's `initialize` is answered with a `-32022` error naming the modern versions the server does speak, so a well-behaved client learns to go modern rather than being silently dropped. `Posture.LEGACY_ONLY` is the mirror image, a server that speaks only the `initialize` handshake and refuses requests carrying the modern envelope. The low-level `Server` takes the same argument.
129-
130-
Posture lives on the server object, not on any driver, so `run()`, `streamable_http_app()`, and the stream drivers all honour the same declaration and there is no per-transport switch to keep in step. Reach for it deliberately: `MODERN_ONLY` turns away every deployed pre-2026 client, and `LEGACY_ONLY` turns away 2026 ones, so `DUAL` is what you want unless you know exactly who connects.
131-
132114
## Recap
133115

134-
* One `streamable_http_app()` serves both protocol eras. The SDK routes each request by its `MCP-Protocol-Version` header; there is nothing to configure on the transport and no per-request era knob to look for.
135-
* The one era switch is the server's posture: `MCPServer(name, posture=Posture.MODERN_ONLY)` or `LEGACY_ONLY`, a constructor property every transport honours. The default `DUAL` is what this whole page assumes.
116+
* One `streamable_http_app()` (and one stdio server) serves both protocol eras, always. The SDK routes each request by its `MCP-Protocol-Version` header, and a stream connection's era is decided by the client's opening message; there is nothing to configure on the transport or the server and no era switch to look for.
136117
* A legacy client costs you a session: an in-process `Mcp-Session-Id` record with no distributed store behind it. More than one worker means **sticky routing**, or the wrong worker answers `404 Session not found`. **[Deploy & scale](deploy.md)** has the multi-worker story.
137118
* `stateless_http=True` is the one knob, and it is **legacy-leg-only**. It buys free load balancing for legacy clients at the price of both server-to-client channels on that leg: server-initiated requests raise `NoBackChannelError` (a top-level error at the client, not an `is_error` result), and notifications are dropped.
138119
* A `2026-07-28` connection is sessionless either way. `stateless_http` never touches it.

docs/whats-new.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ Each of these is a section in the **[Migration Guide](migration.md)**:
153153

154154
## The protocol: 2025-11-25 to 2026-07-28
155155

156-
v2 implements the 2026-07-28 revision, and it serves **both** revisions at once: the same `streamable_http_app()` (and the same stdio server) answers a 2025-era client's `initialize` and a 2026-era client's requests with nothing to configure, no flag to flip, and no separate deployment. Serving the new revision does not strand a client on the old one. If you want to serve only one revision, that is a single constructor property, `Server(posture=...)` / `MCPServer(posture=...)`, honoured by every transport; see [Serving one era only](run/legacy-clients.md#serving-one-era-only). What follows is what the new revision itself changes.
156+
v2 implements the 2026-07-28 revision, and it serves **both** revisions at once: the same `streamable_http_app()` (and the same stdio server) answers a 2025-era client's `initialize` and a 2026-era client's requests with nothing to configure, no flag to flip, and no separate deployment. Serving the new revision does not strand a client on the old one. What follows is what the new revision itself changes.
157157

158158
### No handshake, no session
159159

src/mcp/server/__init__.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from .lowlevel import NotificationOptions, Server
44
from .mcpserver import MCPServer
55
from .models import InitializationOptions
6-
from .serving import Posture, serve_listener, serve_stream
6+
from .serving import serve_listener, serve_stream
77

88
__all__ = [
99
"CacheHint",
@@ -12,7 +12,6 @@
1212
"MCPServer",
1313
"NotificationOptions",
1414
"InitializationOptions",
15-
"Posture",
1615
"serve_listener",
1716
"serve_stream",
1817
]

src/mcp/server/lowlevel/server.py

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ async def main():
5858
from mcp.server.caching import CacheableMethod, CacheHint, validate_cache_hints
5959
from mcp.server.context import HandlerResult, ServerMiddleware, ServerRequestContext
6060
from mcp.server.models import InitializationOptions
61-
from mcp.server.serving import Posture, serve_stream
61+
from mcp.server.serving import serve_stream
6262
from mcp.server.streamable_http import EventStore
6363
from mcp.server.streamable_http_manager import (
6464
DEFAULT_MAX_REQUEST_BODY_SIZE,
@@ -143,7 +143,6 @@ def __init__(
143143
website_url: str | None = None,
144144
icons: list[types.Icon] | None = None,
145145
cache_hints: Mapping[CacheableMethod, CacheHint] | None = None,
146-
posture: Posture = Posture.DUAL,
147146
lifespan: Callable[
148147
[Server[LifespanResultT]],
149148
AbstractAsyncContextManager[LifespanResultT],
@@ -227,7 +226,6 @@ def __init__(
227226
website_url: str | None = None,
228227
icons: list[types.Icon] | None = None,
229228
cache_hints: Mapping[CacheableMethod, CacheHint] | None = None,
230-
posture: Posture = Posture.DUAL,
231229
lifespan: Callable[
232230
[Server[LifespanResultT]],
233231
AbstractAsyncContextManager[LifespanResultT],
@@ -320,7 +318,6 @@ def __init__(
320318
website_url: str | None = None,
321319
icons: list[types.Icon] | None = None,
322320
cache_hints: Mapping[CacheableMethod, CacheHint] | None = None,
323-
posture: Posture = Posture.DUAL,
324321
lifespan: Callable[
325322
[Server[LifespanResultT]],
326323
AbstractAsyncContextManager[LifespanResultT],
@@ -428,8 +425,6 @@ def __init__(
428425
self.instructions = instructions
429426
self.website_url = website_url
430427
self.icons = icons
431-
# Which protocol eras this server offers, on every transport.
432-
self.posture: Posture = posture
433428
# Per-method `ttl_ms`/`cache_scope` fills, applied by `ServerRunner`
434429
# after the handler returns; fields the handler set explicitly win.
435430
self.cache_hints: dict[str, CacheHint] = validate_cache_hints(cache_hints)
@@ -716,8 +711,8 @@ async def run(
716711
"""Serve one connection over a duplex message stream until the read side closes.
717712
718713
Enters the server's lifespan, lets the client's opening message pick
719-
the connection's protocol era (subject to `self.posture`), and serves
720-
that era until EOF; `initialization_options` defaults to
714+
the connection's protocol era, and serves that era until EOF;
715+
`initialization_options` defaults to
721716
`create_initialization_options()`. For a socket host use `serve_listener`;
722717
for connections sharing a lifespan, `serve_stream(..., lifespan_state=)`.
723718
"""

src/mcp/server/mcpserver/server.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,6 @@
8484
from mcp.server.mcpserver.utilities.context_injection import find_context_parameter
8585
from mcp.server.mcpserver.utilities.logging import configure_logging, get_logger
8686
from mcp.server.request_state import RequestStateBoundary, RequestStateSecurity
87-
from mcp.server.serving import Posture
8887
from mcp.server.sse import SseServerTransport
8988
from mcp.server.stdio import stdio_server
9089
from mcp.server.streamable_http import EventStore
@@ -185,7 +184,6 @@ def __init__(
185184
request_state_security: RequestStateSecurity | None = None,
186185
cache_hints: Mapping[CacheableMethod, CacheHint] | None = None,
187186
subscriptions: SubscriptionBus | None = None,
188-
posture: Posture = Posture.DUAL,
189187
):
190188
self._resource_security = resource_security
191189
self.settings = Settings(
@@ -219,7 +217,6 @@ def __init__(
219217
icons=icons,
220218
version=version,
221219
cache_hints=cache_hints,
222-
posture=posture,
223220
on_list_tools=self._handle_list_tools,
224221
on_call_tool=self._handle_call_tool,
225222
on_list_resources=self._handle_list_resources,

src/mcp/server/serving.py

Lines changed: 15 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,14 @@
11
"""Serving a `Server` over a duplex message stream (stdio, SSE, custom sockets).
22
3-
`serve_stream` is the driver for stream transports. The client's opening
4-
messages decide the connection's protocol era, in receive order, among those
5-
`Server(posture=)` offers, and that era serves every later message; `Posture`
6-
names which eras a server offers at all.
3+
`serve_stream` is the driver for stream transports. A server offers both
4+
protocol eras on every connection; the client's opening messages decide the
5+
connection's era, in receive order, and that era serves every later message.
76
"""
87

98
from __future__ import annotations
109

1110
import logging
1211
from collections.abc import Awaitable, Mapping
13-
from enum import Enum
1412
from typing import TYPE_CHECKING, Any, Generic, Literal
1513

1614
import anyio
@@ -34,7 +32,7 @@
3432
if TYPE_CHECKING:
3533
from mcp.server.lowlevel.server import Server
3634

37-
__all__ = ["Posture", "serve_listener", "serve_stream"]
35+
__all__ = ["serve_listener", "serve_stream"]
3836

3937
logger = logging.getLogger(__name__)
4038

@@ -47,19 +45,6 @@
4745
"""The one notification that completes a legacy handshake and so opens the legacy era."""
4846

4947

50-
class Posture(Enum):
51-
"""Which protocol eras a server offers on a connection; `DUAL` lets the opening message decide."""
52-
53-
DUAL = "dual"
54-
"""Both eras: the legacy `initialize` handshake and the modern per-request envelope."""
55-
56-
LEGACY_ONLY = "legacy-only"
57-
"""Only the legacy `initialize` handshake era."""
58-
59-
MODERN_ONLY = "modern-only"
60-
"""Only the modern per-request-envelope era (2026-07-28+)."""
61-
62-
6348
class _Unset:
6449
"""Sentinel type for an omitted `lifespan_state` (`None` is a valid state)."""
6550

@@ -159,8 +144,8 @@ async def _ignore_stray_notification() -> None:
159144
class _StreamConnection(Generic[LifespanT]):
160145
"""One duplex stream connection: admits every message and routes it to the connection's era.
161146
162-
`_era` starts from the server's posture and, under `DUAL`, is decided once, in
163-
receive order, by the first era-distinctive message the dispatcher admits.
147+
`_era` starts undecided and is decided once, in receive order, by the first
148+
era-distinctive message the dispatcher admits.
164149
"""
165150

166151
def __init__(
@@ -169,7 +154,6 @@ def __init__(
169154
read_stream: ReadStream[SessionMessage | Exception],
170155
write_stream: WriteStream[SessionMessage],
171156
*,
172-
posture: Posture,
173157
lifespan_state: LifespanT,
174158
init_options: InitializationOptions | None,
175159
session_id: str | None,
@@ -188,13 +172,7 @@ def __init__(
188172
ServerRunner(server, loop_connection, lifespan_state, init_options=init_options)
189173
)
190174
self._modern: _ModernEra[LifespanT] = _ModernEra(server, lifespan_state, NotifyOnlyOutbound(self._dispatcher))
191-
# Posture is consumed here, once: which eras exist for this connection.
192-
starting_era: dict[Posture, _LegacyEra[LifespanT] | _ModernEra[LifespanT] | None] = {
193-
Posture.DUAL: None,
194-
Posture.LEGACY_ONLY: self._legacy,
195-
Posture.MODERN_ONLY: self._modern,
196-
}
197-
self._era: _LegacyEra[LifespanT] | _ModernEra[LifespanT] | None = starting_era[posture]
175+
self._era: _LegacyEra[LifespanT] | _ModernEra[LifespanT] | None = None
198176

199177
def _transport_context(self, _metadata: MessageMetadata) -> TransportContext:
200178
# Admission has already decided this frame's era; only the legacy era
@@ -239,6 +217,11 @@ def _admit_notification(self, method: str, params: Mapping[str, Any] | None) ->
239217
self._era = self._legacy
240218
return False
241219

220+
def open_legacy_era(self) -> None:
221+
"""Open the legacy era before any message: the transport routed this stream to
222+
the handshake era ahead of its first frame (streamable HTTP's stateful sessions)."""
223+
self._era = self._legacy
224+
242225
def _on_request(
243226
self, dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
244227
) -> Awaitable[dict[str, Any]]:
@@ -274,8 +257,8 @@ async def serve_stream(
274257
"""Serve `server` over a duplex message stream until the read side closes.
275258
276259
The driver for stream transports: the client's opening messages decide the
277-
connection's era among those `server.posture` offers. Enters
278-
`server.lifespan()` unless `lifespan_state` is given.
260+
connection's era. Enters `server.lifespan()` unless `lifespan_state` is
261+
given.
279262
280263
Args:
281264
initialization_options: The legacy handshake's `InitializeResult`
@@ -301,7 +284,6 @@ async def serve_stream(
301284
server,
302285
read_stream,
303286
write_stream,
304-
posture=server.posture,
305287
lifespan_state=lifespan_state,
306288
init_options=initialization_options,
307289
session_id=None,
@@ -327,12 +309,12 @@ async def serve_legacy_stream(
327309
server,
328310
read_stream,
329311
write_stream,
330-
posture=Posture.LEGACY_ONLY,
331312
lifespan_state=lifespan_state,
332313
init_options=None,
333314
session_id=session_id,
334315
raise_exceptions=False,
335316
)
317+
connection.open_legacy_era()
336318
await connection.run()
337319

338320

0 commit comments

Comments
 (0)