mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-17 19:19:12 +02:00
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)
498 lines
19 KiB
Python
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"
|