fastmcp/tests/server/test_context.py
Jeremiah Lowin 3a9717e6be
Publish docs for v3.2.0 (#3713)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Jeremiah Lowin <jlowin@users.noreply.github.com>
Co-authored-by: Marvin Context Protocol <41898282+Marvin Context Protocol@users.noreply.github.com>
Co-authored-by: voidborne-d <voidborne-d@users.noreply.github.com>
Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: d 🔹 <258577966+voidborne-d@users.noreply.github.com>
Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Co-authored-by: nightcityblade <nightcityblade@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Bill Easton <strawgate@users.noreply.github.com>
Co-authored-by: Sumanshu Nankana <sumanshunankana@gmail.com>
Co-authored-by: Eric Robinson <ericrobinson@indeed.com>
Co-authored-by: Martim Santos <martimfasantos@gmail.com>
Co-authored-by: d 🔹 <liusway405@gmail.com>
Co-authored-by: Matthieu B <66959271+mtthidoteu@users.noreply.github.com>
Co-authored-by: Sascha Buehrle <47737812+saschabuehrle@users.noreply.github.com>
Co-authored-by: Hakancan <142545736+hkc5@users.noreply.github.com>
Co-authored-by: nightcityblade <jackchen@haloailabs.com>
Co-authored-by: Matt Hallowell <17804673+mhallo@users.noreply.github.com>
Co-authored-by: nate nowack <thrast36@gmail.com>
Co-authored-by: Bill Easton <williamseaston@gmail.com>
Co-authored-by: Marcus Shu <46469249+shulkx@users.noreply.github.com>
Co-authored-by: Rushabh Doshi <radoshi@gmail.com>
Co-authored-by: AIKAWA Shigechika <shige@aikawa.jp>
Co-authored-by: Jeremy Simon <simonjer805@gmail.com>
Co-authored-by: Miguel Miranda Dias <7780875+pandego@users.noreply.github.com>
Co-authored-by: Anthony James Padavano <padavano.anthony@gmail.com>
Co-authored-by: Mostafa Kamal <hiremostafa@gmail.com>
Fix auto-close MRE script posting comment without closing (#3386)
Fix WorkOS token scope verification bypass 🤖 Generated with Codex (#3407)
Fix initialize McpError fallthrough 🤖 Generated with Codex (#3413)
Fix transform arg collisions with passthrough params (#3431)
Fix get_* returning None when latest version is disabled (#3439)
Fix get_* returning None when latest version is disabled (#3421)
Fix server lifespan overlap teardown (#3415)
Fix $ref output schema object detection regression (#3420)
resolved annotations (#3429)
Fix async partial callables rejected by iscoroutinefunction (#3438)
Fix async partial callables rejected by iscoroutinefunction (#3423)
fix: add version to components (#3458)
fix: use intent-based flag for OIDC scope patch in load_access_token (#3465)
Fixes #3461
fix: normalize Google scope shorthands and surface valid_scopes (#3477)
fix: resolve ty 0.0.23 type-checking errors and bump pin (#3481)
fix: shield lifespan teardown from cancellation (#3480)
fix: forward custom_route endpoints from mounted servers (#3462)
fix updates _get_additional_http_routes() to traverse providers,
Fixes #3457
fix: remove hardcoded version from CLI help text (#3456)
fix: monty 0.0.8 compatibility, drop external_functions from constructor (#3468)
fix: task test teardown hanging 5s per test (#3499)
Closes #3498
fix: validate workspace path is a directory before cursor install (#3440)
Fixes #3426
fix: handle re.error from malformed URI templates in build_regex (#3501)
fix: reject empty/OIDC-only required_scopes in AzureProvider (#3503)
fix: restrict $ref resolution to local refs only (SSRF/LFI) (#3502)
fix warnings and timeouts (#3504)
close upgrade check issue when build passes (#3505)
Closes #3484
fix: URL-encode path params to prevent SSRF/path traversal (GHSA-vv7q-7jx5-f767) (#3507)
fix: prevent path traversal in skill download (#3493)
fix: prefer IdP-granted scopes over client-requested scopes in OAuthProxy (#3492)
fix: remove unrelated transform and http.py changes from PR scope
fix: remove forced follow_redirects from httpx_client_factory calls (#3496)
fix: stop passing follow_redirects to httpx_client_factory
fix: restore follow_redirects=True for custom httpx client factories
Closes #3509
fix: CSRF double-submit cookie check in consent flow (#3519)
fix: validate server names in install commands (#3522)
fix: use raw strings for regex in pytest.raises match (#3523)
fix: reject refresh tokens used as Bearer access tokens (#3524)
fix: route ResourcesAsTools/PromptsAsTools through server middleware (#3495)
fix: resolve Pyright "Module is not callable" on @tool, @resource, @prompt decorators (#3540)
fix: filter warnings by message in KEY_PREFIX test (#3549)
fix: suppress output schema for ToolResult subclass annotations (#3548)
fix: increase sleep duration in proxy cache tests (#3567)
fix: store absolute token expiry to prevent stale expires_in on reload (#3572)
fix: preserve tool properties named 'title' during schema compression (#3582)
Fix loopback redirect URI port matching per RFC 8252 §7.3 (#3589)
Fix app tool routing: visibility check and middleware propagation (#3591)
Fix query parameter serialization to respect OpenAPI explode/style settings (#3595)
Fix dev apps form: union types, textarea support, JSON parsing (#3597)
fix(google): replace deprecated /oauth2/v1/tokeninfo with /oauth2/v3/userinfo (#3603)
fix: resolve EntraOBOToken dependency injection through MultiAuth (#3609)
fix(docs): correct misleading stateless_http header (#3622)
fix: filesystem provider import machinery (#3626)
Closes #3625 (issues 2, 3, 6)
fix: recover StdioTransport after subprocess exits (#3630)
fix(server): preserve mounted tool task metadata (#3632)
fix: scope deprecation warning filter to FastMCPDeprecationWarning (#3649)
fix imports, add PrefabAppConfig (#3650)
fix: resolve CurrentFastMCP/ctx.fastmcp to child server in mounted background tasks (#3651)
Fix blocking docs issues: chart imports, Select API, Rx consistency (#3652)
closed by default (#3657)
Fix prompt caching middleware missing wrap/unwrap round-trip (#3666)
fix: serialize object query params per OpenAPI style/explode rules (#3662)
Fixes #2857
fix: HTTP request headers not accessible in background task workers (#3631)
fix: restore HTTP headers in worker execution path for background tasks (#3681)
fix: strip discriminator after dereferencing schemas (#3682)
fix: remove stale ty:ignore directives for ty 0.0.26 (#3684)
Fix docs gaps in app provider pages (#3690)
fix: dev apps log panel UX improvements (#3698)
fix dev server empty string args (#3700)
2026-03-30 16:48:30 -04:00

498 lines
19 KiB
Python

from typing import Any, cast
from unittest.mock import MagicMock
import pytest
from mcp.types import ModelPreferences
from fastmcp.server.context import (
Context,
reset_transport,
set_transport,
)
from fastmcp.server.sampling.run import _parse_model_preferences
from fastmcp.server.server import FastMCP
@pytest.fixture
def context():
return Context(fastmcp=FastMCP())
class TestParseModelPreferences:
def test_parse_model_preferences_string(self, context):
mp = _parse_model_preferences("claude-haiku-4-5")
assert isinstance(mp, ModelPreferences)
assert mp.hints is not None
assert mp.hints[0].name == "claude-haiku-4-5"
def test_parse_model_preferences_list(self, context):
mp = _parse_model_preferences(["claude-haiku-4-5", "claude"])
assert isinstance(mp, ModelPreferences)
assert mp.hints is not None
assert [h.name for h in mp.hints] == ["claude-haiku-4-5", "claude"]
def test_parse_model_preferences_object(self, context):
obj = ModelPreferences(hints=[])
assert _parse_model_preferences(obj) is obj
def test_parse_model_preferences_invalid_type(self, context):
with pytest.raises(ValueError):
_parse_model_preferences(model_preferences=123) # pyright: ignore[reportArgumentType] # type: ignore[invalid-argument-type] # ty:ignore[invalid-argument-type]
class TestSessionId:
def test_session_id_with_http_headers(self, context):
"""Test that session_id returns the value from mcp-session-id header."""
from mcp.server.lowlevel.server import request_ctx
from mcp.shared.context import RequestContext
mock_headers = {"mcp-session-id": "test-session-123"}
token = request_ctx.set(
RequestContext(
request_id=0,
meta=None,
session=MagicMock(wraps={}),
lifespan_context=MagicMock(),
request=MagicMock(headers=mock_headers),
)
)
try:
assert context.session_id == "test-session-123"
finally:
request_ctx.reset(token)
def test_session_id_without_http_headers(self, context):
"""Test that session_id returns a UUID when no HTTP headers are available.
For STDIO/SSE/in-memory transports, we generate a UUID and cache it
on the session for consistency with state operations.
"""
import uuid
from mcp.server.lowlevel.server import request_ctx
from mcp.shared.context import RequestContext
mock_session = MagicMock(wraps={})
token = request_ctx.set(
RequestContext(
request_id=0,
meta=None,
session=mock_session,
lifespan_context=MagicMock(),
)
)
try:
# session_id should be a valid UUID for non-HTTP transports
session_id = context.session_id
assert uuid.UUID(session_id) # Valid UUID format
# Should be cached on session
assert mock_session._fastmcp_state_prefix == session_id
finally:
request_ctx.reset(token)
class TestContextState:
"""Test suite for Context state functionality."""
async def test_context_state_basic(self):
"""Test basic get/set/delete state operations."""
server = FastMCP("test")
mock_session = MagicMock() # Use same session for consistent id()
async with Context(fastmcp=server, session=mock_session) as context:
# Initially empty
assert await context.get_state("test1") is None
assert await context.get_state("test2") is None
# Set values
await context.set_state("test1", "value")
await context.set_state("test2", 2)
# Retrieve values
assert await context.get_state("test1") == "value"
assert await context.get_state("test2") == 2
# Update value
await context.set_state("test1", "new_value")
assert await context.get_state("test1") == "new_value"
# Delete value
await context.delete_state("test1")
assert await context.get_state("test1") is None
async def test_context_state_session_isolation(self):
"""Test that different sessions have isolated state."""
server = FastMCP("test")
session_a = MagicMock()
session_b = MagicMock()
async with Context(fastmcp=server, session=session_a) as context1:
await context1.set_state("key", "value-from-A")
async with Context(fastmcp=server, session=session_b) as context2:
# Session B should not see session A's state
assert await context2.get_state("key") is None
await context2.set_state("key", "value-from-B")
assert await context2.get_state("key") == "value-from-B"
# Verify session A's state is still intact
async with Context(fastmcp=server, session=session_a) as context3:
assert await context3.get_state("key") == "value-from-A"
async def test_context_state_persists_across_requests(self):
"""Test that state persists across multiple context instances (requests)."""
server = FastMCP("test")
mock_session = MagicMock() # Same session = same id()
# First request sets state
async with Context(fastmcp=server, session=mock_session) as context1:
await context1.set_state("counter", 1)
# Second request in same session sees the state
async with Context(fastmcp=server, session=mock_session) as context2:
counter = await context2.get_state("counter")
assert counter == 1
await context2.set_state("counter", counter + 1)
# Third request sees updated state
async with Context(fastmcp=server, session=mock_session) as context3:
assert await context3.get_state("counter") == 2
async def test_context_state_nested_contexts_share_state(self):
"""Test that nested contexts within the same session share state."""
server = FastMCP("test")
mock_session = MagicMock()
async with Context(fastmcp=server, session=mock_session) as context1:
await context1.set_state("key", "outer-value")
async with Context(fastmcp=server, session=mock_session) as context2:
# Nested context sees same state (same session)
assert await context2.get_state("key") == "outer-value"
# Nested context can modify shared state
await context2.set_state("key", "inner-value")
# Outer context sees the modification
assert await context1.get_state("key") == "inner-value"
async def test_two_clients_same_key_isolated_by_session(self):
"""Test that two different clients can store the same key independently.
Each client gets an auto-generated session ID, and their state is isolated.
"""
import json
from fastmcp import Client
server = FastMCP("test")
stored_session_ids: list[str] = []
@server.tool
async def store_and_read(value: str, ctx: Context) -> dict:
"""Store a value and return all state info."""
stored_session_ids.append(ctx.session_id)
existing = await ctx.get_state("shared_key")
await ctx.set_state("shared_key", value)
new_value = await ctx.get_state("shared_key")
return {
"session_id": ctx.session_id,
"existing_value": existing,
"new_value": new_value,
}
# Client 1 stores "value-from-client-1"
async with Client(server) as client1:
result1 = await client1.call_tool(
"store_and_read", {"value": "value-from-client-1"}
)
data1 = json.loads(result1.content[0].text)
assert data1["existing_value"] is None # First write
assert data1["new_value"] == "value-from-client-1"
session_id_1 = data1["session_id"]
# Client 2 stores "value-from-client-2" with the SAME key
async with Client(server) as client2:
result2 = await client2.call_tool(
"store_and_read", {"value": "value-from-client-2"}
)
data2 = json.loads(result2.content[0].text)
# Client 2 should NOT see client 1's value (different session)
assert data2["existing_value"] is None
assert data2["new_value"] == "value-from-client-2"
session_id_2 = data2["session_id"]
# Verify session IDs were auto-generated and are different
assert session_id_1 is not None
assert session_id_2 is not None
assert session_id_1 != session_id_2
# Client 1 reconnects and should still see their value
async with Client(server) as client1_again:
# But this is a NEW session (new connection = new session ID)
result3 = await client1_again.call_tool(
"store_and_read", {"value": "value-from-client-1-again"}
)
data3 = json.loads(result3.content[0].text)
# New session, so existing value is None
assert data3["existing_value"] is None
assert data3["session_id"] != session_id_1 # Different session
class TestContextStateSerializable:
"""Tests for the serializable parameter on set_state."""
async def test_set_state_serializable_false_stores_arbitrary_objects(self):
"""Non-serializable objects can be stored with serializable=False."""
server = FastMCP("test")
mock_session = MagicMock()
class MyClient:
def __init__(self):
self.connected = True
client = MyClient()
async with Context(fastmcp=server, session=mock_session) as context:
await context.set_state("client", client, serializable=False)
result = await context.get_state("client")
assert result is client
assert result.connected is True
async def test_set_state_serializable_false_does_not_persist_across_requests(self):
"""Non-serializable state is request-scoped and gone in a new context."""
server = FastMCP("test")
mock_session = MagicMock()
async with Context(fastmcp=server, session=mock_session) as context:
await context.set_state("key", object(), serializable=False)
assert await context.get_state("key") is not None
async with Context(fastmcp=server, session=mock_session) as context:
assert await context.get_state("key") is None
async def test_set_state_serializable_true_rejects_non_serializable(self):
"""Default set_state raises TypeError for non-serializable values."""
server = FastMCP("test")
mock_session = MagicMock()
async with Context(fastmcp=server, session=mock_session) as context:
with pytest.raises(TypeError, match="serializable=False"):
await context.set_state("key", object())
async def test_set_state_serializable_false_shadows_session_state(self):
"""Request-scoped state shadows session-scoped state for the same key."""
server = FastMCP("test")
mock_session = MagicMock()
async with Context(fastmcp=server, session=mock_session) as context:
await context.set_state("key", "session-value")
assert await context.get_state("key") == "session-value"
await context.set_state("key", "request-value", serializable=False)
assert await context.get_state("key") == "request-value"
async def test_delete_state_removes_from_both_stores(self):
"""delete_state clears both request-scoped and session-scoped values."""
server = FastMCP("test")
mock_session = MagicMock()
async with Context(fastmcp=server, session=mock_session) as context:
await context.set_state("key", "session-value")
await context.set_state("key", "request-value", serializable=False)
assert await context.get_state("key") == "request-value"
await context.delete_state("key")
assert await context.get_state("key") is None
async def test_serializable_state_still_persists_across_requests(self):
"""Serializable state (default) still persists across requests."""
server = FastMCP("test")
mock_session = MagicMock()
async with Context(fastmcp=server, session=mock_session) as context:
await context.set_state("key", "persistent")
async with Context(fastmcp=server, session=mock_session) as context:
assert await context.get_state("key") == "persistent"
async def test_serializable_write_clears_request_scoped_shadow(self):
"""Writing serializable state clears any request-scoped shadow for the same key."""
server = FastMCP("test")
mock_session = MagicMock()
async with Context(fastmcp=server, session=mock_session) as context:
await context.set_state("key", "request-value", serializable=False)
assert await context.get_state("key") == "request-value"
# Serializable write should clear the shadow
await context.set_state("key", "session-value")
assert await context.get_state("key") == "session-value"
class TestContextMeta:
"""Test suite for Context meta functionality."""
def test_request_context_meta_access(self, context):
"""Test that meta can be accessed from request context."""
from mcp.server.lowlevel.server import request_ctx
from mcp.shared.context import RequestContext
# Create a mock meta object with attributes
class MockMeta:
def __init__(self):
self.user_id = "user-123"
self.trace_id = "trace-456"
self.custom_field = "custom-value"
mock_meta = MockMeta()
token = request_ctx.set(
RequestContext(
request_id=0,
meta=cast(Any, mock_meta), # Mock object for testing
session=MagicMock(wraps={}),
lifespan_context=MagicMock(),
)
)
# Access meta through context
retrieved_meta = context.request_context.meta
assert retrieved_meta is not None
assert retrieved_meta.user_id == "user-123"
assert retrieved_meta.trace_id == "trace-456"
assert retrieved_meta.custom_field == "custom-value"
request_ctx.reset(token)
def test_request_context_meta_none(self, context):
"""Test that context handles None meta gracefully."""
from mcp.server.lowlevel.server import request_ctx
from mcp.shared.context import RequestContext
token = request_ctx.set(
RequestContext(
request_id=0,
meta=None,
session=MagicMock(wraps={}),
lifespan_context=MagicMock(),
)
)
# Access meta through context
retrieved_meta = context.request_context.meta
assert retrieved_meta is None
request_ctx.reset(token)
class TestTransport:
"""Test suite for Context transport property."""
def test_transport_returns_none_outside_server_context(self, context):
"""Test that transport returns None when not in a server context."""
assert context.transport is None
def test_transport_returns_stdio(self, context):
"""Test that transport returns 'stdio' when set."""
token = set_transport("stdio")
try:
assert context.transport == "stdio"
finally:
reset_transport(token)
def test_transport_returns_sse(self, context):
"""Test that transport returns 'sse' when set."""
token = set_transport("sse")
try:
assert context.transport == "sse"
finally:
reset_transport(token)
def test_transport_returns_streamable_http(self, context):
"""Test that transport returns 'streamable-http' when set."""
token = set_transport("streamable-http")
try:
assert context.transport == "streamable-http"
finally:
reset_transport(token)
def test_transport_reset(self, context):
"""Test that transport resets correctly."""
assert context.transport is None
token = set_transport("stdio")
assert context.transport == "stdio"
reset_transport(token)
assert context.transport is None
class TestTransportIntegration:
"""Integration tests for transport property with actual server/client."""
async def test_transport_in_tool_via_client(self):
"""Test that transport is accessible from within a tool via Client."""
from fastmcp import Client
mcp = FastMCP("test")
observed_transport = None
@mcp.tool
def get_transport(ctx: Context) -> str:
nonlocal observed_transport
observed_transport = ctx.transport
return observed_transport or "none"
# Client uses in-memory transport which doesn't set transport type
# so we expect None here (the transport is only set by run_* methods)
async with Client(mcp) as client:
result = await client.call_tool("get_transport", {})
assert observed_transport is None
assert result.data == "none"
async def test_transport_set_manually_is_visible_in_tool(self):
"""Test that manually set transport is visible from within a tool."""
from fastmcp import Client
mcp = FastMCP("test")
observed_transport = None
@mcp.tool
def get_transport(ctx: Context) -> str:
nonlocal observed_transport
observed_transport = ctx.transport
return observed_transport or "none"
# Manually set transport before running
token = set_transport("stdio")
try:
async with Client(mcp) as client:
result = await client.call_tool("get_transport", {})
assert observed_transport == "stdio"
assert result.data == "stdio"
finally:
reset_transport(token)
async def test_transport_set_via_http_middleware(self):
"""Test that transport is set per-request via HTTP middleware."""
from fastmcp import Client
from fastmcp.client.transports import StreamableHttpTransport
from fastmcp.utilities.tests import run_server_async
mcp = FastMCP("test")
observed_transport = None
@mcp.tool
def get_transport(ctx: Context) -> str:
nonlocal observed_transport
observed_transport = ctx.transport
return observed_transport or "none"
async with run_server_async(mcp, transport="streamable-http") as url:
transport = StreamableHttpTransport(url=url)
async with Client(transport=transport) as client:
result = await client.call_tool("get_transport", {})
assert observed_transport == "streamable-http"
assert result.data == "streamable-http"