fastmcp/tests/server/test_session_provider.py

569 lines
22 KiB
Python

"""End-to-end tests for the two session-state patterns and `SessionProvider`.
Covers the injected `session: UserSession` per-user pattern, the explicit
`session_id: SessionId` argument pattern (with its create-then-validate
lifecycle), and the `SessionProvider` that supplies the `create_session` /
`end_session` lifecycle tools. The schema, registration, and lifecycle paths run
through an in-memory `Client`; the principal-isolation cases drive the tool
through its full injection + storage path under a simulated authenticated
principal.
"""
import re
from collections.abc import Iterator
from contextlib import contextmanager
from typing import Any
from uuid import UUID
import pytest
from mcp.server.auth.middleware.auth_context import auth_context_var
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
from mcp.server.auth.provider import AccessToken as SDKAccessToken
from fastmcp import Client, FastMCP
from fastmcp.exceptions import ToolError
from fastmcp.server.context import Context
from fastmcp.server.dependencies import get_session
from fastmcp.server.sessions import (
SESSION_ID_DESCRIPTION,
InvalidSession,
SessionId,
SessionProvider,
UserSession,
)
from fastmcp.tools.base import ToolResult
def result_value(result: ToolResult) -> Any:
"""The `"result"` field of a direct `Tool.run()` call's structured content.
`structured_content` is `dict | None` on `ToolResult` (a bare function tool
always populates it, but the type isn't narrowed by construction), so this
asserts it is present before indexing.
"""
assert result.structured_content is not None
return result.structured_content["result"]
def make_token(*, subject: str = "user-a") -> SDKAccessToken:
return SDKAccessToken(
token="opaque",
client_id="client-1",
scopes=[],
subject=subject,
claims={"iss": "https://issuer.example"},
)
@contextmanager
def as_principal(token: SDKAccessToken | None) -> Iterator[None]:
if token is None:
yield
return
reset = auth_context_var.set(AuthenticatedUser(token))
try:
yield
finally:
auth_context_var.reset(reset)
def build_injected_server() -> FastMCP:
"""Server whose cart tools inject a per-user `session: UserSession`."""
server = FastMCP("shop")
@server.tool
async def add_to_cart(item: str, session: UserSession) -> int:
cart = await session.get("cart", default=[])
cart.append(item)
await session.set("cart", cart)
return len(cart)
@server.tool
async def view_cart(session: UserSession) -> list[str]:
return await session.get("cart", default=[])
return server
def build_id_server() -> FastMCP:
"""Server whose cart tools take an explicit `session_id: SessionId`."""
server = FastMCP("shop")
server.add_provider(SessionProvider())
@server.tool
async def add_to_cart(item: str, session_id: SessionId) -> int:
session = await get_session(session_id)
cart = await session.get("cart", default=[])
cart.append(item)
await session.set("cart", cart)
return len(cart)
@server.tool
async def view_cart(session_id: SessionId) -> list[str]:
session = await get_session(session_id)
return await session.get("cart", default=[])
return server
# ---------------------------------------------------------------------------
# Injected `session: UserSession`
# ---------------------------------------------------------------------------
class TestInjectedSession:
async def test_not_in_input_schema(self):
server = build_injected_server()
async with Client(server) as client:
tools = {t.name: t for t in await client.list_tools()}
schema = tools["add_to_cart"].input_schema
assert "session" not in schema["properties"]
assert "item" in schema["properties"]
async def test_errors_without_auth(self):
server = build_injected_server()
async with Client(server) as client:
with pytest.raises(ToolError):
await client.call_tool("view_cart", {})
async def test_state_survives_across_calls_per_user(self):
server = build_injected_server()
add_tool = await server.get_tool("add_to_cart")
view_tool = await server.get_tool("view_cart")
assert add_tool is not None
assert view_tool is not None
async with Context(fastmcp=server):
with as_principal(make_token(subject="user-a")):
await add_tool.run({"item": "apple"})
second = await add_tool.run({"item": "banana"})
view = await view_tool.run({})
assert result_value(second) == 2
assert result_value(view) == ["apple", "banana"]
async def test_two_principals_get_isolated_buckets(self):
server = build_injected_server()
add_tool = await server.get_tool("add_to_cart")
view_tool = await server.get_tool("view_cart")
assert add_tool is not None
assert view_tool is not None
async with Context(fastmcp=server):
with as_principal(make_token(subject="user-a")):
await add_tool.run({"item": "apple"})
with as_principal(make_token(subject="user-b")):
view_b = await view_tool.run({})
assert result_value(view_b) == []
async def test_injected_value_is_a_user_session_instance(self):
"""The handler receives a `UserSession`, not a bare `Session` — so
`isinstance(session, UserSession)` holds for code that keys off it."""
server = FastMCP("shop")
@server.tool
async def whoami(session: UserSession) -> bool:
return isinstance(session, UserSession)
tool = await server.get_tool("whoami")
assert tool is not None
async with Context(fastmcp=server):
with as_principal(make_token()):
result = await tool.run({})
assert result_value(result) is True
async def test_optional_session_is_none_without_auth(self):
"""`session: UserSession | None = None` injects `None` on an
unauthenticated request instead of raising."""
server = FastMCP("shop")
@server.tool
async def maybe(session: UserSession | None = None) -> bool:
return session is None
tool = await server.get_tool("maybe")
assert tool is not None
async with Context(fastmcp=server):
result = await tool.run({})
assert result_value(result) is True
async def test_optional_session_is_present_with_auth(self):
"""The same optional parameter injects a real `UserSession` when the
request is authenticated."""
server = FastMCP("shop")
@server.tool
async def maybe(session: UserSession | None = None) -> bool:
return isinstance(session, UserSession)
tool = await server.get_tool("maybe")
assert tool is not None
async with Context(fastmcp=server):
with as_principal(make_token()):
result = await tool.run({})
assert result_value(result) is True
async def test_optional_session_not_in_input_schema(self):
"""An optional injected session is still excluded from the schema."""
server = FastMCP("shop")
@server.tool
async def maybe(session: UserSession | None = None) -> bool:
return session is None
async with Client(server) as client:
tools = {t.name: t for t in await client.list_tools()}
assert "session" not in tools["maybe"].input_schema.get("properties", {})
async def test_user_session_needs_no_provider(self):
"""A server using only `UserSession` requires no `SessionProvider` and
lists no lifecycle tools."""
server = build_injected_server()
async with Client(server) as client:
names = {t.name for t in await client.list_tools()}
assert "create_session" not in names
assert "end_session" not in names
async def test_storage_key_does_not_embed_the_raw_principal(self):
"""The injected session is stored under the reserved per-user id, not
under the raw principal JSON — proven by reconstructing a `Session`
against the reserved id and reading back what injection wrote."""
from fastmcp.server.sessions import (
_USER_SESSION_ID,
Session,
current_principal,
session_storage_key,
)
server = build_injected_server()
add_tool = await server.get_tool("add_to_cart")
assert add_tool is not None
token = make_token(subject="user-a")
async with Context(fastmcp=server):
with as_principal(token):
await add_tool.run({"item": "apple"})
principal = current_principal()
assert principal is not None
assert token.subject is not None
# The raw principal never appears in the storage key itself.
key = session_storage_key(principal, _USER_SESSION_ID)
assert principal not in key
assert token.subject not in key
assert token.client_id not in key
# And the reserved-id reconstruction reads back what injection wrote,
# proving injection actually used `_USER_SESSION_ID` as the session id.
reconstructed = Session(
store=server._state_store,
principal=principal,
session_id=_USER_SESSION_ID,
)
assert await reconstructed.get("cart") == ["apple"]
# ---------------------------------------------------------------------------
# Explicit `session_id: SessionId` — create then validate
# ---------------------------------------------------------------------------
class TestSessionIdArgument:
async def test_session_id_is_a_required_string_with_contract_description(self):
server = build_id_server()
async with Client(server) as client:
tools = {t.name: t for t in await client.list_tools()}
schema = tools["view_cart"].input_schema
prop = schema["properties"]["session_id"]
assert prop["type"] == "string"
assert "session_id" in schema["required"]
assert prop["description"] == SESSION_ID_DESCRIPTION
async def test_created_id_round_trips_state_across_calls(self):
server = build_id_server()
async with Client(server) as client:
session_id = (await client.call_tool("create_session", {})).data
await client.call_tool(
"add_to_cart", {"item": "apple", "session_id": session_id}
)
second = await client.call_tool(
"add_to_cart", {"item": "banana", "session_id": session_id}
)
assert second.data == 2
view = await client.call_tool("view_cart", {"session_id": session_id})
assert view.data == ["apple", "banana"]
async def test_resolved_session_exposes_its_id(self):
"""A session resolved from a `session_id` argument carries that id."""
server = FastMCP("shop")
server.add_provider(SessionProvider())
@server.tool
async def which_session(session_id: SessionId) -> str | None:
return (await get_session(session_id)).id
async with Client(server) as client:
session_id = (await client.call_tool("create_session", {})).data
result = (
await client.call_tool("which_session", {"session_id": session_id})
).data
assert result == session_id
async def test_uncreated_id_is_rejected(self):
"""An id that was never handed out by `create_session` does not resolve."""
server = build_id_server()
async with Client(server) as client:
with pytest.raises(ToolError):
await client.call_tool("view_cart", {"session_id": "never-created"})
async def test_distinct_created_ids_are_isolated(self):
server = build_id_server()
async with Client(server) as client:
id_a = (await client.call_tool("create_session", {})).data
id_b = (await client.call_tool("create_session", {})).data
assert id_a != id_b
await client.call_tool("add_to_cart", {"item": "apple", "session_id": id_a})
view_b = await client.call_tool("view_cart", {"session_id": id_b})
assert view_b.data == []
async def test_end_session_invalidates_the_session(self):
"""After `end_session` the id no longer resolves at all."""
server = build_id_server()
async with Client(server) as client:
session_id = (await client.call_tool("create_session", {})).data
await client.call_tool(
"add_to_cart", {"item": "apple", "session_id": session_id}
)
await client.call_tool("end_session", {"session_id": session_id})
with pytest.raises(ToolError):
await client.call_tool("view_cart", {"session_id": session_id})
async def test_clear_keeps_the_session_valid(self):
"""`session.clear()` empties state but the session still resolves."""
server = FastMCP("shop")
server.add_provider(SessionProvider())
@server.tool
async def add_to_cart(item: str, session_id: SessionId) -> int:
session = await get_session(session_id)
cart = await session.get("cart", default=[])
cart.append(item)
await session.set("cart", cart)
return len(cart)
@server.tool
async def clear_cart(session_id: SessionId) -> str:
session = await get_session(session_id)
await session.clear()
return "cleared"
@server.tool
async def view_cart(session_id: SessionId) -> list[str]:
session = await get_session(session_id)
return await session.get("cart", default=[])
async with Client(server) as client:
session_id = (await client.call_tool("create_session", {})).data
await client.call_tool(
"add_to_cart", {"item": "apple", "session_id": session_id}
)
await client.call_tool("clear_cart", {"session_id": session_id})
# Still resolves (no error), and state is empty.
view = await client.call_tool("view_cart", {"session_id": session_id})
assert view.data == []
async def test_created_under_one_principal_rejected_under_another(self):
"""An id created by principal A is rejected when used by principal B."""
server = build_id_server()
create_tool = await server.get_tool("create_session")
view_tool = await server.get_tool("view_cart")
assert create_tool is not None
assert view_tool is not None
async with Context(fastmcp=server):
with as_principal(make_token(subject="user-a")):
created = await create_tool.run({})
session_id = result_value(created)
with as_principal(make_token(subject="user-b")):
with pytest.raises(InvalidSession):
await view_tool.run({"session_id": session_id})
with as_principal(make_token(subject="user-a")):
# A's own session still resolves.
view_a = await view_tool.run({"session_id": session_id})
assert result_value(view_a) == []
async def test_two_principals_same_id_are_isolated(self):
server = build_id_server()
create_tool = await server.get_tool("create_session")
add_tool = await server.get_tool("add_to_cart")
view_tool = await server.get_tool("view_cart")
assert create_tool is not None
assert add_tool is not None
assert view_tool is not None
async with Context(fastmcp=server):
with as_principal(make_token(subject="user-a")):
id_a = result_value(await create_tool.run({}))
await add_tool.run({"item": "apple", "session_id": id_a})
with as_principal(make_token(subject="user-b")):
id_b = result_value(await create_tool.run({}))
# B's own session under its own id is empty.
view_b = await view_tool.run({"session_id": id_b})
assert result_value(view_b) == []
with as_principal(make_token(subject="user-a")):
view_a = await view_tool.run({"session_id": id_a})
assert result_value(view_a) == ["apple"]
# ---------------------------------------------------------------------------
# SessionProvider
# ---------------------------------------------------------------------------
class TestSessionProvider:
async def test_lifecycle_tools_registered_via_add_provider(self):
server = FastMCP("s")
server.add_provider(SessionProvider())
async with Client(server) as client:
names = {t.name for t in await client.list_tools()}
assert {"create_session", "end_session"} <= names
async def test_create_session_returns_a_uuid_string(self):
server = FastMCP("s")
server.add_provider(SessionProvider())
async with Client(server) as client:
session_id = (await client.call_tool("create_session", {})).data
assert isinstance(session_id, str)
# Parses as a uuid4 and is unguessable (not a fixed/empty value).
assert str(UUID(session_id)) == session_id
async def test_create_session_ids_are_distinct(self):
server = FastMCP("s")
server.add_provider(SessionProvider())
async with Client(server) as client:
first = (await client.call_tool("create_session", {})).data
second = (await client.call_tool("create_session", {})).data
assert first != second
async def test_end_session_declares_session_id_contract(self):
server = FastMCP("s")
server.add_provider(SessionProvider())
async with Client(server) as client:
tools = {t.name: t for t in await client.list_tools()}
prop = tools["end_session"].input_schema["properties"]["session_id"]
assert prop["type"] == "string"
assert SESSION_ID_DESCRIPTION in prop["description"]
class TestNoProviderIsNonFatal:
"""With the enforcement checks removed, a `session_id` tool without a
`SessionProvider` is not a setup error — it simply cannot resolve a session,
because no id can be created. The failure surfaces at use, not at listing."""
async def test_session_id_tool_lists_without_a_provider(self):
server = FastMCP("shop")
@server.tool
async def add_to_cart(item: str, session_id: SessionId) -> int:
return len(item)
async with Client(server) as client:
names = {t.name for t in await client.list_tools()}
assert names == {"add_to_cart"}
async def test_any_id_is_rejected_without_a_way_to_create_one(self):
server = FastMCP("shop")
@server.tool
async def add_to_cart(item: str, session_id: SessionId) -> str:
session = await get_session(session_id)
await session.set("item", item)
return "ok"
# No provider, so no id was ever minted: resolution rejects any id.
async with Client(server) as client:
with pytest.raises(ToolError):
await client.call_tool(
"add_to_cart", {"item": "x", "session_id": "made-up"}
)
class TestSessionIdDescriptionAppending:
async def test_author_description_is_preserved_and_appended(self):
server = FastMCP("s")
server.add_provider(SessionProvider())
@server.tool
async def resume(session_id: SessionId) -> str:
"""Resume work.
Args:
session_id: The handle for this workflow.
"""
return session_id
async with Client(server) as client:
tools = {t.name: t for t in await client.list_tools()}
desc = tools["resume"].input_schema["properties"]["session_id"]["description"]
assert "The handle for this workflow." in desc
assert SESSION_ID_DESCRIPTION in desc
# Author text comes first, contract appended after.
assert re.search(r"handle for this workflow\.\s+Session identifier\.", desc)
async def test_contract_is_not_duplicated_when_author_repeats_it(self):
"""An author who already includes the contract text doesn't get it twice."""
from typing import Annotated
from pydantic import Field
server = FastMCP("s")
server.add_provider(SessionProvider())
@server.tool
async def resume(
session_id: Annotated[SessionId, Field(description=SESSION_ID_DESCRIPTION)],
) -> str:
return session_id
async with Client(server) as client:
tools = {t.name: t for t in await client.list_tools()}
desc = tools["resume"].input_schema["properties"]["session_id"]["description"]
assert desc.count(SESSION_ID_DESCRIPTION) == 1
async def test_description_survives_namespaced_mount(self):
"""The contract names no specific tool, so it stays correct when a mount
renames the lifecycle tool under a namespace — the description must not
point agents at an unqualified `create_session` that does not exist
under that mount."""
child = FastMCP("child")
child.add_provider(SessionProvider())
@child.tool
async def workflow(session_id: SessionId) -> str:
return session_id
parent = FastMCP("parent")
parent.mount(child, namespace="child")
async with Client(parent) as client:
tools = {t.name: t for t in await client.list_tools()}
# The lifecycle tool is renamed under the namespace...
assert "child_create_session" in tools
assert "create_session" not in tools
# ...yet the session_id contract still resolves correctly, because it
# describes the capability rather than naming a tool.
desc = tools["child_workflow"].input_schema["properties"]["session_id"][
"description"
]
assert desc == SESSION_ID_DESCRIPTION
assert "create_session" not in desc