Skip to content

Commit 9fb9ce6

Browse files
committed
Merge branch 'main' into main
2 parents 310eb73 + 58c5e72 commit 9fb9ce6

File tree

85 files changed

+8305
-285
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

85 files changed

+8305
-285
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,4 +166,5 @@ cython_debug/
166166

167167
# vscode
168168
.vscode/
169+
.windsurfrules
169170
**/CLAUDE.local.md

CLAUDE.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ This document contains critical information about working with this codebase. Fo
1919
- Line length: 88 chars maximum
2020

2121
3. Testing Requirements
22-
- Framework: `uv run pytest`
22+
- Framework: `uv run --frozen pytest`
2323
- Async testing: use anyio, not asyncio
2424
- Coverage: test edge cases and errors
2525
- New features require tests
@@ -54,9 +54,9 @@ This document contains critical information about working with this codebase. Fo
5454
## Code Formatting
5555

5656
1. Ruff
57-
- Format: `uv run ruff format .`
58-
- Check: `uv run ruff check .`
59-
- Fix: `uv run ruff check . --fix`
57+
- Format: `uv run --frozen ruff format .`
58+
- Check: `uv run --frozen ruff check .`
59+
- Fix: `uv run --frozen ruff check . --fix`
6060
- Critical issues:
6161
- Line length (88 chars)
6262
- Import sorting (I001)
@@ -67,7 +67,7 @@ This document contains critical information about working with this codebase. Fo
6767
- Imports: split into multiple lines
6868

6969
2. Type Checking
70-
- Tool: `uv run pyright`
70+
- Tool: `uv run --frozen pyright`
7171
- Requirements:
7272
- Explicit None checks for Optional
7373
- Type narrowing for strings
@@ -104,6 +104,10 @@ This document contains critical information about working with this codebase. Fo
104104
- Add None checks
105105
- Narrow string types
106106
- Match existing patterns
107+
- Pytest:
108+
- If the tests aren't finding the anyio pytest mark, try adding PYTEST_DISABLE_PLUGIN_AUTOLOAD=""
109+
to the start of the pytest run command eg:
110+
`PYTEST_DISABLE_PLUGIN_AUTOLOAD="" uv run --frozen pytest`
107111

108112
3. Best Practices
109113
- Check git status before commits

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,33 @@ async def long_task(files: list[str], ctx: Context) -> str:
309309
return "Processing complete"
310310
```
311311

312+
### Authentication
313+
314+
Authentication can be used by servers that want to expose tools accessing protected resources.
315+
316+
`mcp.server.auth` implements an OAuth 2.0 server interface, which servers can use by
317+
providing an implementation of the `OAuthServerProvider` protocol.
318+
319+
```
320+
mcp = FastMCP("My App",
321+
auth_provider=MyOAuthServerProvider(),
322+
auth=AuthSettings(
323+
issuer_url="https://myapp.com",
324+
revocation_options=RevocationOptions(
325+
enabled=True,
326+
),
327+
client_registration_options=ClientRegistrationOptions(
328+
enabled=True,
329+
valid_scopes=["myscope", "myotherscope"],
330+
default_scopes=["myscope"],
331+
),
332+
required_scopes=["myscope"],
333+
),
334+
)
335+
```
336+
337+
See [OAuthServerProvider](mcp/server/auth/provider.py) for more details.
338+
312339
## Running Your Server
313340

314341
### Development Mode

examples/clients/simple-chatbot/mcp_simple_chatbot/main.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -122,8 +122,10 @@ async def list_tools(self) -> list[Any]:
122122

123123
for item in tools_response:
124124
if isinstance(item, tuple) and item[0] == "tools":
125-
for tool in item[1]:
126-
tools.append(Tool(tool.name, tool.description, tool.inputSchema))
125+
tools.extend(
126+
Tool(tool.name, tool.description, tool.inputSchema)
127+
for tool in item[1]
128+
)
127129

128130
return tools
129131

@@ -282,10 +284,9 @@ def __init__(self, servers: list[Server], llm_client: LLMClient) -> None:
282284

283285
async def cleanup_servers(self) -> None:
284286
"""Clean up all servers properly."""
285-
cleanup_tasks = []
286-
for server in self.servers:
287-
cleanup_tasks.append(asyncio.create_task(server.cleanup()))
288-
287+
cleanup_tasks = [
288+
asyncio.create_task(server.cleanup()) for server in self.servers
289+
]
289290
if cleanup_tasks:
290291
try:
291292
await asyncio.gather(*cleanup_tasks, return_exceptions=True)
@@ -322,8 +323,7 @@ async def process_llm_response(self, llm_response: str) -> str:
322323
total = result["total"]
323324
percentage = (progress / total) * 100
324325
logging.info(
325-
f"Progress: {progress}/{total} "
326-
f"({percentage:.1f}%)"
326+
f"Progress: {progress}/{total} ({percentage:.1f}%)"
327327
)
328328

329329
return f"Tool execution result: {result}"

examples/servers/simple-prompt/mcp_simple_prompt/server.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ async def get_prompt(
9090
if transport == "sse":
9191
from mcp.server.sse import SseServerTransport
9292
from starlette.applications import Starlette
93+
from starlette.responses import Response
9394
from starlette.routing import Mount, Route
9495

9596
sse = SseServerTransport("/messages/")
@@ -101,6 +102,7 @@ async def handle_sse(request):
101102
await app.run(
102103
streams[0], streams[1], app.create_initialization_options()
103104
)
105+
return Response()
104106

105107
starlette_app = Starlette(
106108
debug=True,

examples/servers/simple-resource/mcp_simple_resource/server.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ async def read_resource(uri: FileUrl) -> str | bytes:
4646
if transport == "sse":
4747
from mcp.server.sse import SseServerTransport
4848
from starlette.applications import Starlette
49+
from starlette.responses import Response
4950
from starlette.routing import Mount, Route
5051

5152
sse = SseServerTransport("/messages/")
@@ -57,11 +58,12 @@ async def handle_sse(request):
5758
await app.run(
5859
streams[0], streams[1], app.create_initialization_options()
5960
)
61+
return Response()
6062

6163
starlette_app = Starlette(
6264
debug=True,
6365
routes=[
64-
Route("/sse", endpoint=handle_sse),
66+
Route("/sse", endpoint=handle_sse, methods=["GET"]),
6567
Mount("/messages/", app=sse.handle_post_message),
6668
],
6769
)
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# MCP Simple StreamableHttp Stateless Server Example
2+
3+
A stateless MCP server example demonstrating the StreamableHttp transport without maintaining session state. This example is ideal for understanding how to deploy MCP servers in multi-node environments where requests can be routed to any instance.
4+
5+
## Features
6+
7+
- Uses the StreamableHTTP transport in stateless mode (mcp_session_id=None)
8+
- Each request creates a new ephemeral connection
9+
- No session state maintained between requests
10+
- Task lifecycle scoped to individual requests
11+
- Suitable for deployment in multi-node environments
12+
13+
14+
## Usage
15+
16+
Start the server:
17+
18+
```bash
19+
# Using default port 3000
20+
uv run mcp-simple-streamablehttp-stateless
21+
22+
# Using custom port
23+
uv run mcp-simple-streamablehttp-stateless --port 3000
24+
25+
# Custom logging level
26+
uv run mcp-simple-streamablehttp-stateless --log-level DEBUG
27+
28+
# Enable JSON responses instead of SSE streams
29+
uv run mcp-simple-streamablehttp-stateless --json-response
30+
```
31+
32+
The server exposes a tool named "start-notification-stream" that accepts three arguments:
33+
34+
- `interval`: Time between notifications in seconds (e.g., 1.0)
35+
- `count`: Number of notifications to send (e.g., 5)
36+
- `caller`: Identifier string for the caller
37+
38+
39+
## Client
40+
41+
You can connect to this server using an HTTP client. For now, only the TypeScript SDK has streamable HTTP client examples, or you can use [Inspector](https://github.com/modelcontextprotocol/inspector) for testing.

examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/__init__.py

Whitespace-only changes.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from .server import main
2+
3+
if __name__ == "__main__":
4+
main()
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import contextlib
2+
import logging
3+
4+
import anyio
5+
import click
6+
import mcp.types as types
7+
from mcp.server.lowlevel import Server
8+
from mcp.server.streamableHttp import (
9+
StreamableHTTPServerTransport,
10+
)
11+
from starlette.applications import Starlette
12+
from starlette.routing import Mount
13+
14+
logger = logging.getLogger(__name__)
15+
# Global task group that will be initialized in the lifespan
16+
task_group = None
17+
18+
19+
@contextlib.asynccontextmanager
20+
async def lifespan(app):
21+
"""Application lifespan context manager for managing task group."""
22+
global task_group
23+
24+
async with anyio.create_task_group() as tg:
25+
task_group = tg
26+
logger.info("Application started, task group initialized!")
27+
try:
28+
yield
29+
finally:
30+
logger.info("Application shutting down, cleaning up resources...")
31+
if task_group:
32+
tg.cancel_scope.cancel()
33+
task_group = None
34+
logger.info("Resources cleaned up successfully.")
35+
36+
37+
@click.command()
38+
@click.option("--port", default=3000, help="Port to listen on for HTTP")
39+
@click.option(
40+
"--log-level",
41+
default="INFO",
42+
help="Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)",
43+
)
44+
@click.option(
45+
"--json-response",
46+
is_flag=True,
47+
default=False,
48+
help="Enable JSON responses instead of SSE streams",
49+
)
50+
def main(
51+
port: int,
52+
log_level: str,
53+
json_response: bool,
54+
) -> int:
55+
# Configure logging
56+
logging.basicConfig(
57+
level=getattr(logging, log_level.upper()),
58+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
59+
)
60+
61+
app = Server("mcp-streamable-http-stateless-demo")
62+
63+
@app.call_tool()
64+
async def call_tool(
65+
name: str, arguments: dict
66+
) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]:
67+
ctx = app.request_context
68+
interval = arguments.get("interval", 1.0)
69+
count = arguments.get("count", 5)
70+
caller = arguments.get("caller", "unknown")
71+
72+
# Send the specified number of notifications with the given interval
73+
for i in range(count):
74+
await ctx.session.send_log_message(
75+
level="info",
76+
data=f"Notification {i+1}/{count} from caller: {caller}",
77+
logger="notification_stream",
78+
related_request_id=ctx.request_id,
79+
)
80+
if i < count - 1: # Don't wait after the last notification
81+
await anyio.sleep(interval)
82+
83+
return [
84+
types.TextContent(
85+
type="text",
86+
text=(
87+
f"Sent {count} notifications with {interval}s interval"
88+
f" for caller: {caller}"
89+
),
90+
)
91+
]
92+
93+
@app.list_tools()
94+
async def list_tools() -> list[types.Tool]:
95+
return [
96+
types.Tool(
97+
name="start-notification-stream",
98+
description=(
99+
"Sends a stream of notifications with configurable count"
100+
" and interval"
101+
),
102+
inputSchema={
103+
"type": "object",
104+
"required": ["interval", "count", "caller"],
105+
"properties": {
106+
"interval": {
107+
"type": "number",
108+
"description": "Interval between notifications in seconds",
109+
},
110+
"count": {
111+
"type": "number",
112+
"description": "Number of notifications to send",
113+
},
114+
"caller": {
115+
"type": "string",
116+
"description": (
117+
"Identifier of the caller to include in notifications"
118+
),
119+
},
120+
},
121+
},
122+
)
123+
]
124+
125+
# ASGI handler for stateless HTTP connections
126+
async def handle_streamable_http(scope, receive, send):
127+
logger.debug("Creating new transport")
128+
# Use lock to prevent race conditions when creating new sessions
129+
http_transport = StreamableHTTPServerTransport(
130+
mcp_session_id=None,
131+
is_json_response_enabled=json_response,
132+
)
133+
async with http_transport.connect() as streams:
134+
read_stream, write_stream = streams
135+
136+
if not task_group:
137+
raise RuntimeError("Task group is not initialized")
138+
139+
async def run_server():
140+
await app.run(
141+
read_stream,
142+
write_stream,
143+
app.create_initialization_options(),
144+
# Runs in standalone mode for stateless deployments
145+
# where clients perform initialization with any node
146+
standalone_mode=True,
147+
)
148+
149+
# Start server task
150+
task_group.start_soon(run_server)
151+
152+
# Handle the HTTP request and return the response
153+
await http_transport.handle_request(scope, receive, send)
154+
155+
# Create an ASGI application using the transport
156+
starlette_app = Starlette(
157+
debug=True,
158+
routes=[
159+
Mount("/mcp", app=handle_streamable_http),
160+
],
161+
lifespan=lifespan,
162+
)
163+
164+
import uvicorn
165+
166+
uvicorn.run(starlette_app, host="0.0.0.0", port=port)
167+
168+
return 0

0 commit comments

Comments
 (0)