8000 Merge branch 'main' into client-session-group · modelcontextprotocol/python-sdk@595e777 · GitHub
[go: up one dir, main page]

Skip to content

Commit 595e777

Browse files
authored
Merge branch 'main' into client-session-group
2 parents a42f953 + fdb538b commit 595e777

File tree

12 files changed

+346
-32
lines changed

12 files changed

+346
-32
lines changed

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,7 @@ providing an implementation of the `OAuthServerProvider` protocol.
318318

319319
```
320320
mcp = FastMCP("My App",
321-
auth_provider=MyOAuthServerProvider(),
321+
auth_server_provider=MyOAuthServerProvider(),
322322
auth=AuthSettings(
323323
issuer_url="https://myapp.com",
324324
revocation_options=RevocationOptions(
@@ -426,7 +426,7 @@ mcp = FastMCP(name="MathServer", stateless_http=True)
426426

427427

428428
@mcp.tool(description="A simple add tool")
429-
def add_two(n: int) -> str:
429+
def add_two(n: int) -> int:
430430
return n + 2
431431
```
432432

@@ -462,6 +462,8 @@ The streamable HTTP transport supports:
462462

463463
> **Note**: SSE transport is being superseded by [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http).
464464
465+
By default, SSE servers are mounted at `/sse` and Streamable HTTP servers are mounted at `/mcp`. You can customize these paths using the methods described below.
466+
465467
You can mount the SSE server to an existing ASGI server using the `sse_app` method. This allows you to integrate the SSE server with other ASGI applications.
466468

467469
`` 6D40 `python

examples/servers/simple-auth/README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,31 @@ uv run mcp-simple-auth
4444

4545
The server will start on `http://localhost:8000`.
4646

47+
### Transport Options
48+
49+
This server supports multiple transport protocols that can run on the same port:
50+
51+
#### SSE (Server-Sent Events) - Default
52+
```bash
53+
uv run mcp-simple-auth
54+
# or explicitly:
55+
uv run mcp-simple-auth --transport sse
56+
```
57+
58+
SSE transport provides endpoint:
59+
- `/sse`
60+
61+
#### Streamable HTTP
62+
```bash
63+
uv run mcp-simple-auth --transport streamable-http
64+
```
65+
66+
Streamable HTTP transport provides endpoint:
67+
- `/mcp`
68+
69+
70+
This ensures backward compatibility without needing multiple server instances. When using SSE transport (`--transport sse`), only the `/sse` endpoint is available.
71+
4772
## Available Tool
4873

4974
### get_user_profile
@@ -61,5 +86,6 @@ If the server fails to start, check:
6186
1. Environment variables `MCP_GITHUB_GITHUB_CLIENT_ID` and `MCP_GITHUB_GITHUB_CLIENT_SECRET` are set
6287
2. The GitHub OAuth app callback URL matches `http://localhost:8000/github/callback`
6388
3. No other service is using port 8000
89+
4. The transport specified is valid (`sse` or `streamable-http`)
6490

6591
You can use [Inspector](https://github.com/modelcontextprotocol/inspector) to test Auth

examples/servers/simple-auth/mcp_simple_auth/server.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import logging
44
import secrets
55
import time
6-
from typing import Any
6+
from typing import Any, Literal
77

88
import click
99
from pydantic import AnyHttpUrl
@@ -347,7 +347,13 @@ async def get_user_profile() -> dict[str, Any]:
347347
@click.command()
348348
@click.option("--port", default=8000, help="Port to listen on")
349349
@click.option("--host", default="localhost", help="Host to bind to")
350-
def main(port: int, host: str) -> int:
350+
@click.option(
351+
"--transport",
352+
default="sse",
353+
type=click.Choice(["sse", "streamable-http"]),
354+
help="Transport protocol to use ('sse' or 'streamable-http')",
355+
)
356+
def main(port: int, host: str, transport: Literal["sse", "streamable-http"]) -> int:
351357
"""Run the simple GitHub MCP server."""
352358
logging.basicConfig(level=logging.INFO)
353359

@@ -364,5 +370,6 @@ def main(port: int, host: str) -> int:
364370
return 1
365371

366372
mcp_server = create_simple_mcp_server(settings)
367-
mcp_server.run(transport="sse")
373+
logger.info(f"Starting server with {transport} transport")
374+
mcp_server.run(transport=transport)
368375
return 0

src/mcp/cli/claude.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ def get_claude_config_path() -> Path | None:
3131
return path
3232
return None
3333

34+
3435
def get_uv_path() -> str:
3536
"""Get the full path to the uv executable."""
3637
uv_path = shutil.which("uv")
@@ -42,6 +43,7 @@ def get_uv_path() -> str:
4243
return "uv" # Fall back to just "uv" if not found
4344
return uv_path
4445

46+
4547
def update_claude_config(
4648
file_spec: str,
4749
server_name: str,

src/mcp/client/streamable_http.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
import anyio
1717
import httpx
18+
from anyio.abc import TaskGroup
1819
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
1920
from httpx_sse import EventSource, ServerSentEvent, aconnect_sse
2021

@@ -239,7 +240,7 @@ async def _handle_resumption_request(self, ctx: RequestContext) -> None:
239240
break
240241

241242
async def _handle_post_request(self, ctx: RequestContext) -> None:
242-
"""Handle a POST request with response processing."""
243+
"""Handle a POST request with response processing."""
243244
headers = self._update_headers_with_session(ctx.headers)
244245
message = ctx.session_message.message
245246
is_initialization = self._is_initialization_request(message)
@@ -300,7 +301,7 @@ async def _handle_sse_response(
300301
try:
301302
event_source = EventSource(response)
302303
async for sse in event_source.aiter_sse():
303-
await self._handle_sse_event(
304+
is_complete = await self._handle_sse_event(
304305
sse,
305306
ctx.read_stream_writer,
306307
resumption_callback=(
@@ -309,6 +310,10 @@ async def _handle_sse_response(
309310
else None
310311
),
311312
)
313+
# If the SSE event indicates completion, like returning respose/error
314+
# break the loop
315+
if is_complete:
316+
break
312317
except Exception as e:
313318
logger.exception("Error reading SSE stream:")
314319
await ctx.read_stream_writer.send(e)
@@ -344,6 +349,7 @@ async def post_writer(
344349
read_stream_writer: StreamWriter,
345350
write_stream: MemoryObjectSendStream[SessionMessage],
346351
start_get_stream: Callable[[], None],
352+
tg: TaskGroup,
347353
) -> None:
348354
"""Handle writing requests to the server."""
349355
try:
@@ -375,10 +381,17 @@ async def post_writer(
375381
sse_read_timeout=self.sse_read_timeout,
376382
)
377383

378-
if is_resumption:
379-
await self._handle_resumption_request(ctx)
384+
async def handle_request_async():
385+
if is_resumption:
386+
await self._handle_resumption_request(ctx)
387+
else:
388+
await self._handle_post_request(ctx)
389+
390+
# If this is a request, start a new task to handle it
391+
if isinstance(message.root, JSONRPCRequest):
392+
tg.start_soon(handle_request_async)
380393
else:
381-
await self._handle_post_request(ctx)
394+
await handle_request_async()
382395

383396
except Exception as exc:
384397
logger.error(f"Error in post_writer: {exc}")
@@ -397,7 +410,7 @@ async def terminate_session(self, client: httpx.AsyncClient) -> None:
397410

398411
if response.status_code == 405:
399412
logger.debug("Server does not allow session termination")
400-
elif response.status_code != 200:
413+
elif response.s 1241 tatus_code not in (200, 204):
401414
logger.warning(f"Session termination failed: {response.status_code}")
402415
except Exception as exc:
403416
logger.warning(f"Session termination failed: {exc}")
@@ -466,6 +479,7 @@ def start_get_stream() -> None:
466479
read_stream_writer,
467480
write_stream,
468481
start_get_stream,
482+
tg,
469483
)
470484

471485
try:

src/mcp/server/session.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ async def handle_list_prompts(ctx: RequestContext) -> list[types.Prompt]:
4747

4848
import mcp.types as types
4949
from mcp.server.models import InitializationOptions
50-
from mcp.shared.message import SessionMessage
50+
from mcp.shared.message import ServerMessageMetadata, SessionMessage
5151
from mcp.shared.session import (
5252
BaseSession,
5353
RequestResponder,
@@ -230,10 +230,11 @@ async def create_message(
230230
stop_sequences: list[str] | None = None,
231231
metadata: dict[str, Any] | None = None,
232232
model_preferences: types.ModelPreferences | None = None,
233+
related_request_id: types.RequestId | None = None,
233234
) -> types.CreateMessageResult:
234235
"""Send a sampling/create_message request."""
235236
return await self.send_request(
236-
types.ServerRequest(
237+
request=types.ServerRequest(
237238
types.CreateMessageRequest(
238239
method="sampling/createMessage",
239240
params=types.CreateMessageRequestParams(
@@ -248,7 +249,10 @@ async def create_message(
248249
),
249250
)
250251
),
251-
types.CreateMessageResult,
252+
result_type=types.CreateMessageResult,
253+
metadata=ServerMessageMetadata(
254+
related_request_id=related_request_id,
255+
),
252256
)
253257

254258
async def list_roots(self) -> types.ListRootsResult:

src/mcp/server/sse.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,19 +100,37 @@ async def connect_sse(self, scope: Scope, receive: Receive, send: Send):
100100
write_stream, write_stream_reader = anyio.create_memory_object_stream(0)
101101

102102
session_id = uuid4()
103-
session_uri = f"{quote(self._endpoint)}?session_id={session_id.hex}"
104103
self._read_stream_writers[session_id] = read_stream_writer
105104
logger.debug(f"Created new session with ID: {session_id}")
106105

106+
# Determine the full path for the message endpoint to be sent to the client.
107+
# scope['root_path'] is the prefix where the current Starlette app
108+
# instance is mounted.
109+
# e.g., "" if top-level, or "/api_prefix" if mounted under "/api_prefix".
110+
root_path = scope.get("root_path", "")
111+
112+
# self._endpoint is the path *within* this app, e.g., "/messages".
113+
# Concatenating them gives the full absolute path from the server root.
114+
# e.g., "" + "/messages" -> "/messages"
115+
# e.g., "/api_prefix" + "/messages" -> "/api_prefix/messages"
116+
full_message_path_for_client = root_path.rstrip("/") + self._endpoint
117+
118+
# This is the URI (path + query) the client will use to POST messages.
119+
client_post_uri_data = (
120+
f"{quote(full_message_path_for_client)}?session_id={session_id.hex}"
121+
)
122+
107123
sse_stream_writer, sse_stream_reader = anyio.create_memory_object_stream[
108124
dict[str, Any]
109125
](0)
110126

111127
async def sse_writer():
112128
logger.debug("Starting SSE writer")
113129
async with sse_stream_writer, write_stream_reader:
114-
await sse_stream_writer.send({"event": "endpoint", "data": session_uri})
115-
logger.debug(f"Sent endpoint event: {session_uri}")
130+
await sse_stream_writer.send(
131+
{"event": "endpoint", "data": client_post_uri_data}
132+
)
133+
logger.debug(f"Sent endpoint event: {client_post_uri_data}")
116134

117135
async for session_message in write_stream_reader:
118136
logger.debug(f"Sending message via SSE: {session_message}")

src/mcp/server/streamable_http.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@
3333
ErrorData,
3434
JSONRPCError,
3535
JSONRPCMessage,
36-
JSONRPCNotification,
3736
JSONRPCRequest,
3837
JSONRPCResponse,
3938
RequestId,
@@ -849,9 +848,15 @@ async def message_router():
849848
# Determine which request stream(s) should receive this message
850849
message = session_message.message
851850
target_request_id = None
852-
if isinstance(
853-
message.root, JSONRPCNotification | JSONRPCRequest
854-
):
851+
# Check if this is a response
852+
if isinstance(message.root, JSONRPCResponse | JSONRPCError):
853+
response_id = str(message.root.id)
854+
# If this response is for an existing request stream,
855+
# send it there
856+
if response_id in self._request_streams:
857+
target_request_id = response_id
858+
859+
else:
855860
# Extract related_request_id from meta if it exists
856861
if (
857862
session_message.metadata is not None
@@ -865,10 +870,12 @@ async def message_router():
865870
target_request_id = str(
866871
session_message.metadata.related_request_id
867872
)
868-
else:
869-
target_request_id = str(message.root.id)
870873

871-
request_stream_id = target_request_id or GET_STREAM_KEY
874+
request_stream_id = (
875+
target_request_id
876+
if target_request_id is not None
877+
else GET_STREAM_KEY
878+
)
872879

873880
# Store the event if we have an event store,
874881
# regardless of whether a client is connected

src/mcp/shared/session.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,6 @@ async def send_request(
223223
Do not use this method to emit notifications! Use send_notification()
224224
instead.
225225
"""
226-
227226
request_id = self._request_id
228227
self._request_id = request_id + 1
229228

tests/client/test_config.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ def test_absolute_uv_path(mock_config_path: Path):
5454
"""Test that the absolute path to uv is used when available."""
5555
# Mock the shutil.which function to return a fake path
5656
mock_uv_path = "/usr/local/bin/uv"
57-
57+
5858
with patch("mcp.cli.claude.get_uv_path", return_value=mock_uv_path):
5959
# Setup
6060
server_name = "test_server"
@@ -71,5 +71,5 @@ def test_absolute_uv_path(mock_config_path: Path):
7171
# Verify the command is the absolute path
7272
server_config = config["mcpServers"][server_name]
7373
command = server_config["command"]
74-
75-
assert command == mock_uv_path
74+
75+
assert command == mock_uv_path

0 commit comments

Comments
 (0)
0