Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/mcp/server/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
from mcp.types import (
INTERNAL_ERROR,
INVALID_PARAMS,
INVALID_REQUEST,
LATEST_PROTOCOL_VERSION,
METHOD_NOT_FOUND,
ErrorData,
Expand Down Expand Up @@ -251,6 +252,8 @@ async def _inner() -> HandlerResult:
# the gate become a per-version legacy path then. Initialize runs inline
# (read loop parked), so awaiting the peer anywhere on this path deadlocks.
if method == "initialize":
if self.connection.client_params is not None:
raise MCPError(code=INVALID_REQUEST, message="Server is already initialized")
return self._handle_initialize(params)
# Methods without a handler are METHOD_NOT_FOUND regardless of
# initialization state: JSON-RPC 2.0 reserves -32601 for "not
Expand Down
4 changes: 2 additions & 2 deletions tests/interaction/_connect.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,12 +274,12 @@ def base_headers(*, session_id: str | None = None) -> dict[str, str]:
return headers


def initialize_body(request_id: int = 1) -> dict[str, object]:
def initialize_body(request_id: int = 1, *, client_name: str = "raw") -> dict[str, object]:
"""A wire-level initialize JSON-RPC request body, exactly as an SDK client would send it."""
params = InitializeRequestParams(
protocol_version=LATEST_PROTOCOL_VERSION,
capabilities=ClientCapabilities(),
client_info=Implementation(name="raw", version="0.0.0"),
client_info=Implementation(name=client_name, version="0.0.0"),
)
return JSONRPCRequest(
jsonrpc="2.0", id=request_id, method="initialize", params=params.model_dump(by_alias=True, exclude_none=True)
Expand Down
6 changes: 0 additions & 6 deletions tests/interaction/_requirements.py
Original file line number Diff line number Diff line change
Expand Up @@ -2508,12 +2508,6 @@ def __post_init__(self) -> None:
source="sdk",
behavior="A second initialize on an already-initialized session transport is rejected.",
transports=("streamable-http",),
divergence=Divergence(
note=(
"The transport forwards a second initialize carrying the existing session ID to the running "
"server, which answers it as a fresh handshake; nothing rejects re-initialization."
),
),
removed_in="2026-07-28",
note=(
"removed in 2026-07-28 (SEP-2567); per-session initialize guard retired with Mcp-Session-Id, no "
Expand Down
79 changes: 67 additions & 12 deletions tests/interaction/transports/test_hosting_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,17 @@
from inline_snapshot import snapshot

from mcp.server import Server, ServerRequestContext
from mcp.types import JSONRPCResponse, ListToolsResult, PaginatedRequestParams, Tool
from mcp.types import (
INVALID_REQUEST,
CallToolRequestParams,
CallToolResult,
JSONRPCError,
JSONRPCResponse,
ListToolsResult,
PaginatedRequestParams,
TextContent,
Tool,
)
from tests.interaction._connect import (
base_headers,
client_via_http,
Expand All @@ -32,9 +42,25 @@ def _server() -> Server:
"""A minimal low-level server with one tool, so subsequent-request routing can be observed."""

async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[Tool(name="noop", description="Does nothing.", input_schema={"type": "object"})])
return ListToolsResult(
tools=[
Tool(name="noop", description="Does nothing.", input_schema={"type": "object"}),
Tool(
name="client-name",
description="Reports the initialized client name.",
input_schema={"type": "object"},
),
]
)

async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
client_params = ctx.session.client_params
assert client_params is not None
assert params.name == "client-name"
client_name = client_params.client_info.name
return CallToolResult(content=[TextContent(text=client_name)], structured_content={"clientName": client_name})

return Server("hosted", on_list_tools=list_tools)
return Server("hosted", on_list_tools=list_tools, on_call_tool=call_tool)


@requirement("hosting:session:create")
Expand Down Expand Up @@ -142,20 +168,49 @@ async def test_terminating_one_session_leaves_others_working() -> None:


@requirement("hosting:session:reinitialize")
async def test_second_initialize_on_an_existing_session_is_accepted() -> None:
"""A second initialize POST carrying an existing session ID is processed rather than rejected.

See the divergence on the requirement: the entry expects a rejection, but the SDK forwards the
second initialize to the running server, which answers it as a fresh handshake.
"""
async def test_second_initialize_on_an_existing_session_is_rejected() -> None:
"""A second initialize POST carrying an existing session ID is rejected without changing client params."""
async with mounted_app(_server()) as (http, manager):
session_id = await initialize_via_http(http)
response, messages = await post_jsonrpc(http, initialize_body(request_id=2), session_id=session_id)
call_body: dict[str, object] = {
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {"name": "client-name"},
}
first_call_response, first_call_messages = await post_jsonrpc(http, call_body, session_id=session_id)

response, messages = await post_jsonrpc(
http, initialize_body(request_id=3, client_name="reinitializer"), session_id=session_id
)
second_call_body: dict[str, object] = {
"jsonrpc": "2.0",
"id": 4,
"method": "tools/call",
"params": {"name": "client-name"},
}
second_call_response, second_call_messages = await post_jsonrpc(
http,
second_call_body,
session_id=session_id,
)
assert len(manager._server_instances) == 1

assert first_call_response.status_code == 200
assert isinstance(first_call_messages[0], JSONRPCResponse)
first_call_result = CallToolResult.model_validate(first_call_messages[0].result)
assert first_call_result.structured_content == {"clientName": "raw"}

assert response.status_code == snapshot(200)
assert isinstance(messages[0], JSONRPCResponse)
assert messages[0].id == 2
assert isinstance(messages[0], JSONRPCError)
assert messages[0].id == 3
assert messages[0].error.code == INVALID_REQUEST
assert messages[0].error.message == "Server is already initialized"

assert second_call_response.status_code == 200
assert isinstance(second_call_messages[0], JSONRPCResponse)
second_call_result = CallToolResult.model_validate(second_call_messages[0].result)
assert second_call_result.structured_content == {"clientName": "raw"}


@requirement("hosting:stateless:no-session-id")
Expand Down
Loading