Spaces:
Running
Running
hopeful0 commited on
Refactor `get_http_request` and `context.session_id` (#1242)
Browse files- src/fastmcp/server/context.py +29 -16
- src/fastmcp/server/dependencies.py +9 -2
- tests/server/test_context.py +30 -24
src/fastmcp/server/context.py
CHANGED
|
@@ -130,7 +130,7 @@ class Context:
|
|
| 130 |
_current_context.reset(token)
|
| 131 |
|
| 132 |
@property
|
| 133 |
-
def request_context(self) -> RequestContext:
|
| 134 |
"""Access to the underlying request context.
|
| 135 |
|
| 136 |
If called outside of a request context, this will raise a ValueError.
|
|
@@ -217,35 +217,48 @@ class Context:
|
|
| 217 |
return str(self.request_context.request_id)
|
| 218 |
|
| 219 |
@property
|
| 220 |
-
def session_id(self) -> str
|
| 221 |
-
"""Get the MCP session ID for
|
| 222 |
|
| 223 |
Returns the session ID that can be used as a key for session-based
|
| 224 |
data storage (e.g., Redis) to share data between tool calls within
|
| 225 |
the same client session.
|
| 226 |
|
| 227 |
Returns:
|
| 228 |
-
The session ID for
|
| 229 |
-
for
|
| 230 |
|
| 231 |
Example:
|
| 232 |
```python
|
| 233 |
@server.tool
|
| 234 |
def store_data(data: dict, ctx: Context) -> str:
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
return "No session ID available (stdio/memory transport)"
|
| 239 |
```
|
| 240 |
"""
|
| 241 |
-
|
| 242 |
-
|
| 243 |
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
|
| 250 |
@property
|
| 251 |
def session(self) -> ServerSession:
|
|
|
|
| 130 |
_current_context.reset(token)
|
| 131 |
|
| 132 |
@property
|
| 133 |
+
def request_context(self) -> RequestContext[ServerSession, Any, Request]:
|
| 134 |
"""Access to the underlying request context.
|
| 135 |
|
| 136 |
If called outside of a request context, this will raise a ValueError.
|
|
|
|
| 217 |
return str(self.request_context.request_id)
|
| 218 |
|
| 219 |
@property
|
| 220 |
+
def session_id(self) -> str:
|
| 221 |
+
"""Get the MCP session ID for ALL transports.
|
| 222 |
|
| 223 |
Returns the session ID that can be used as a key for session-based
|
| 224 |
data storage (e.g., Redis) to share data between tool calls within
|
| 225 |
the same client session.
|
| 226 |
|
| 227 |
Returns:
|
| 228 |
+
The session ID for StreamableHTTP transports, or a generated ID
|
| 229 |
+
for other transports.
|
| 230 |
|
| 231 |
Example:
|
| 232 |
```python
|
| 233 |
@server.tool
|
| 234 |
def store_data(data: dict, ctx: Context) -> str:
|
| 235 |
+
session_id = ctx.session_id
|
| 236 |
+
redis_client.set(f"session:{session_id}:data", json.dumps(data))
|
| 237 |
+
return f"Data stored for session {session_id}"
|
|
|
|
| 238 |
```
|
| 239 |
"""
|
| 240 |
+
request_ctx = self.request_context
|
| 241 |
+
session = request_ctx.session
|
| 242 |
|
| 243 |
+
# Try to get the session ID from the session attributes
|
| 244 |
+
session_id = getattr(session, "_fastmcp_id", None)
|
| 245 |
+
if session_id is not None:
|
| 246 |
+
return session_id
|
| 247 |
+
|
| 248 |
+
# Try to get the session ID from the http request headers
|
| 249 |
+
request = request_ctx.request
|
| 250 |
+
if request:
|
| 251 |
+
session_id = request.headers.get("mcp-session-id")
|
| 252 |
+
|
| 253 |
+
# Generate a session ID if it doesn't exist.
|
| 254 |
+
if session_id is None:
|
| 255 |
+
from uuid import uuid4
|
| 256 |
+
|
| 257 |
+
session_id = str(uuid4())
|
| 258 |
+
|
| 259 |
+
# Save the session id to the session attributes
|
| 260 |
+
setattr(session, "_fastmcp_id", session_id)
|
| 261 |
+
return session_id
|
| 262 |
|
| 263 |
@property
|
| 264 |
def session(self) -> ServerSession:
|
src/fastmcp/server/dependencies.py
CHANGED
|
@@ -37,9 +37,14 @@ def get_context() -> Context:
|
|
| 37 |
|
| 38 |
|
| 39 |
def get_http_request() -> Request:
|
| 40 |
-
from
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
-
request = _current_http_request.get()
|
| 43 |
if request is None:
|
| 44 |
raise RuntimeError("No active HTTP request found.")
|
| 45 |
return request
|
|
@@ -72,6 +77,8 @@ def get_http_headers(include_all: bool = False) -> dict[str, str]:
|
|
| 72 |
"proxy-authenticate",
|
| 73 |
"proxy-authorization",
|
| 74 |
"proxy-connection",
|
|
|
|
|
|
|
| 75 |
}
|
| 76 |
# (just in case)
|
| 77 |
if not all(h.lower() == h for h in exclude_headers):
|
|
|
|
| 37 |
|
| 38 |
|
| 39 |
def get_http_request() -> Request:
|
| 40 |
+
from mcp.server.lowlevel.server import request_ctx
|
| 41 |
+
|
| 42 |
+
request = None
|
| 43 |
+
try:
|
| 44 |
+
request = request_ctx.get().request
|
| 45 |
+
except LookupError:
|
| 46 |
+
pass
|
| 47 |
|
|
|
|
| 48 |
if request is None:
|
| 49 |
raise RuntimeError("No active HTTP request found.")
|
| 50 |
return request
|
|
|
|
| 77 |
"proxy-authenticate",
|
| 78 |
"proxy-authorization",
|
| 79 |
"proxy-connection",
|
| 80 |
+
# MCP-related headers
|
| 81 |
+
"mcp-session-id",
|
| 82 |
}
|
| 83 |
# (just in case)
|
| 84 |
if not all(h.lower() == h for h in exclude_headers):
|
tests/server/test_context.py
CHANGED
|
@@ -91,38 +91,44 @@ class TestParseModelPreferences:
|
|
| 91 |
class TestSessionId:
|
| 92 |
def test_session_id_with_http_headers(self, context):
|
| 93 |
"""Test that session_id returns the value from mcp-session-id header."""
|
|
|
|
|
|
|
|
|
|
| 94 |
mock_headers = {"mcp-session-id": "test-session-123"}
|
| 95 |
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
|
| 101 |
def test_session_id_without_http_headers(self, context):
|
| 102 |
-
"""Test that session_id returns
|
| 103 |
-
|
| 104 |
-
"fastmcp.server.dependencies.get_http_headers",
|
| 105 |
-
side_effect=RuntimeError("No active HTTP request found."),
|
| 106 |
-
):
|
| 107 |
-
assert context.session_id is None
|
| 108 |
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
mock_headers = {"other-header": "value"}
|
| 112 |
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
|
| 118 |
-
|
| 119 |
-
"""Test that session_id returns None when mcp-session-id header is empty."""
|
| 120 |
-
mock_headers = {"mcp-session-id": ""}
|
| 121 |
|
| 122 |
-
|
| 123 |
-
"fastmcp.server.dependencies.get_http_headers", return_value=mock_headers
|
| 124 |
-
):
|
| 125 |
-
assert context.session_id == "" # Empty string is still returned as-is
|
| 126 |
|
| 127 |
|
| 128 |
class TestContextState:
|
|
|
|
| 91 |
class TestSessionId:
|
| 92 |
def test_session_id_with_http_headers(self, context):
|
| 93 |
"""Test that session_id returns the value from mcp-session-id header."""
|
| 94 |
+
from mcp.server.lowlevel.server import request_ctx
|
| 95 |
+
from mcp.shared.context import RequestContext
|
| 96 |
+
|
| 97 |
mock_headers = {"mcp-session-id": "test-session-123"}
|
| 98 |
|
| 99 |
+
token = request_ctx.set(
|
| 100 |
+
RequestContext(
|
| 101 |
+
request_id=0,
|
| 102 |
+
meta=None,
|
| 103 |
+
session=MagicMock(wraps={}),
|
| 104 |
+
lifespan_context=MagicMock(),
|
| 105 |
+
request=MagicMock(headers=mock_headers),
|
| 106 |
+
)
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
assert context.session_id == "test-session-123"
|
| 110 |
+
|
| 111 |
+
request_ctx.reset(token)
|
| 112 |
|
| 113 |
def test_session_id_without_http_headers(self, context):
|
| 114 |
+
"""Test that session_id returns a UUID string when no HTTP headers are available."""
|
| 115 |
+
import uuid
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
|
| 117 |
+
from mcp.server.lowlevel.server import request_ctx
|
| 118 |
+
from mcp.shared.context import RequestContext
|
|
|
|
| 119 |
|
| 120 |
+
token = request_ctx.set(
|
| 121 |
+
RequestContext(
|
| 122 |
+
request_id=0,
|
| 123 |
+
meta=None,
|
| 124 |
+
session=MagicMock(wraps={}),
|
| 125 |
+
lifespan_context=MagicMock(),
|
| 126 |
+
)
|
| 127 |
+
)
|
| 128 |
|
| 129 |
+
assert uuid.UUID(context.session_id)
|
|
|
|
|
|
|
| 130 |
|
| 131 |
+
request_ctx.reset(token)
|
|
|
|
|
|
|
|
|
|
| 132 |
|
| 133 |
|
| 134 |
class TestContextState:
|