Skip to content

Commit f3afb9d

Browse files
committed
feat: set_notification_options that propagates options to low-level server
1 parent 40acbc5 commit f3afb9d

File tree

3 files changed

+150
-5
lines changed

3 files changed

+150
-5
lines changed

src/mcp/server/fastmcp/server.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151
from mcp.server.fastmcp.utilities.context_injection import find_context_parameter
5252
from mcp.server.fastmcp.utilities.logging import configure_logging, get_logger
5353
from mcp.server.lowlevel.helper_types import ReadResourceContents
54-
from mcp.server.lowlevel.server import LifespanResultT
54+
from mcp.server.lowlevel.server import LifespanResultT, NotificationOptions
5555
from mcp.server.lowlevel.server import Server as MCPServer
5656
from mcp.server.lowlevel.server import lifespan as default_lifespan
5757
from mcp.server.session import ServerSession, ServerSessionT
@@ -220,6 +220,7 @@ def __init__( # noqa: PLR0913
220220
self._custom_starlette_routes: list[Route] = []
221221
self.dependencies = self.settings.dependencies
222222
self._session_manager: StreamableHTTPSessionManager | None = None
223+
self._notification_options: NotificationOptions | None = None
223224

224225
# Set up MCP protocol handlers
225226
self._setup_handlers()
@@ -298,6 +299,28 @@ def _setup_handlers(self) -> None:
298299
self._mcp_server.get_prompt()(self.get_prompt)
299300
self._mcp_server.list_resource_templates()(self.list_resource_templates)
300301

302+
def set_notification_options(
303+
self,
304+
*,
305+
prompts_changed: bool = False,
306+
resources_changed: bool = False,
307+
tools_changed: bool = False,
308+
) -> None:
309+
"""Configure which change notifications this server broadcasts
310+
311+
Args:
312+
prompts_changed: Whether to send prompt list changed notifications.
313+
resources_changed: Whether to send resource list changed notifications.
314+
tools_changed: Whether to send tool list changed notifications.
315+
"""
316+
from mcp.server.lowlevel.server import NotificationOptions
317+
318+
self._notification_options = NotificationOptions(
319+
prompts_changed=prompts_changed,
320+
resources_changed=resources_changed,
321+
tools_changed=tools_changed,
322+
)
323+
301324
async def list_tools(self) -> list[MCPTool]:
302325
"""List all available tools."""
303326
tools = self._tool_manager.list_tools()
@@ -732,7 +755,7 @@ async def run_stdio_async(self) -> None:
732755
await self._mcp_server.run(
733756
read_stream,
734757
write_stream,
735-
self._mcp_server.create_initialization_options(),
758+
self._mcp_server.create_initialization_options(self._notification_options),
736759
)
737760

738761
async def run_sse_async(self, mount_path: str | None = None) -> None:
@@ -821,7 +844,7 @@ async def handle_sse(scope: Scope, receive: Receive, send: Send):
821844
await self._mcp_server.run(
822845
streams[0],
823846
streams[1],
824-
self._mcp_server.create_initialization_options(),
847+
self._mcp_server.create_initialization_options(self._notification_options),
825848
)
826849
return Response()
827850

@@ -935,6 +958,7 @@ def streamable_http_app(self) -> Starlette:
935958
json_response=self.settings.json_response,
936959
stateless=self.settings.stateless_http, # Use the stateless setting
937960
security_settings=self.settings.transport_security,
961+
notification_options=self._notification_options,
938962
)
939963

940964
# Create the ASGI handler

src/mcp/server/streamable_http_manager.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from starlette.responses import Response
1616
from starlette.types import Receive, Scope, Send
1717

18+
from mcp.server.lowlevel.server import NotificationOptions
1819
from mcp.server.lowlevel.server import Server as MCPServer
1920
from mcp.server.streamable_http import (
2021
MCP_SESSION_ID_HEADER,
@@ -51,6 +52,9 @@ class StreamableHTTPSessionManager:
5152
json_response: Whether to use JSON responses instead of SSE streams
5253
stateless: If True, creates a completely fresh transport for each request
5354
with no session tracking or state persistence between requests.
55+
notification_options:
56+
Specifies which change notifications (e.g. tools, resources, prompts)
57+
this manager should advertise to clients.
5458
"""
5559

5660
def __init__(
@@ -60,13 +64,17 @@ def __init__(
6064
json_response: bool = False,
6165
stateless: bool = False,
6266
security_settings: TransportSecuritySettings | None = None,
67+
notification_options: NotificationOptions | None = None,
6368
):
6469
self.app = app
6570
self.event_store = event_store
6671
self.json_response = json_response
6772
self.stateless = stateless
6873
self.security_settings = security_settings
6974

75+
# Server notification options
76+
self._notification_options = notification_options
77+
7078
# Session tracking (only used if not stateless)
7179
self._session_creation_lock = anyio.Lock()
7280
self._server_instances: dict[str, StreamableHTTPServerTransport] = {}
@@ -175,7 +183,7 @@ async def run_stateless_server(*, task_status: TaskStatus[None] = anyio.TASK_STA
175183
await self.app.run(
176184
read_stream,
177185
write_stream,
178-
self.app.create_initialization_options(),
186+
self.app.create_initialization_options(self._notification_options),
179187
stateless=True,
180188
)
181189
except Exception:
@@ -241,7 +249,7 @@ async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORE
241249
await self.app.run(
242250
read_stream,
243251
write_stream,
244-
self.app.create_initialization_options(),
252+
self.app.create_initialization_options(self._notification_options),
245253
stateless=False, # Stateful mode
246254
)
247255
except Exception as e:
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""Tests for enabling server notifications."""
2+
3+
from __future__ import annotations
4+
5+
from collections.abc import AsyncIterator
6+
from contextlib import asynccontextmanager
7+
from typing import Any
8+
9+
import pytest
10+
from starlette.applications import Starlette
11+
12+
from mcp.server.fastmcp import FastMCP
13+
from mcp.server.lowlevel.server import NotificationOptions
14+
from mcp.server.models import InitializationOptions
15+
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
16+
17+
18+
@pytest.mark.anyio
19+
async def test_fastmcp_sets_notification_options_affects_initialization():
20+
"""Test that set_notification_options() correctly affects server initialization."""
21+
server = FastMCP("notification-test")
22+
23+
# By default there should be no configuration
24+
assert server._notification_options is None
25+
26+
# Configure notifications
27+
server.set_notification_options(
28+
prompts_changed=True,
29+
resources_changed=True,
30+
tools_changed=False,
31+
)
32+
33+
# Verify internal NotificationOptions created correctly
34+
assert isinstance(server._notification_options, NotificationOptions)
35+
assert server._notification_options.prompts_changed is True
36+
assert server._notification_options.resources_changed is True
37+
assert server._notification_options.tools_changed is False
38+
39+
40+
@pytest.mark.anyio
41+
async def test_streamable_http_session_manager_uses_notification_options() -> None:
42+
# Create the FastMCP server and configure notifications
43+
server = FastMCP("notification-test")
44+
server.set_notification_options(
45+
prompts_changed=True,
46+
resources_changed=False,
47+
tools_changed=True,
48+
)
49+
50+
# Force creation of the StreamableHTTP session manager without starting uvicorn
51+
app = server.streamable_http_app()
52+
53+
assert isinstance(app, Starlette)
54+
55+
# Get the StreamableHTTPSessionManager
56+
assert server._session_manager is not None
57+
session_manager: StreamableHTTPSessionManager = server._session_manager
58+
59+
# Verify internal NotificationOptions created correctly
60+
assert isinstance(session_manager._notification_options, NotificationOptions)
61+
assert session_manager._notification_options.prompts_changed is True
62+
assert session_manager._notification_options.resources_changed is False
63+
assert session_manager._notification_options.tools_changed is True
64+
65+
66+
@pytest.mark.anyio
67+
async def test_run_stdio_uses_configured_notification_options(monkeypatch: pytest.MonkeyPatch) -> None:
68+
"""Verify FastMCP passes NotificationOptions to the low-level server.run call."""
69+
called_with: dict[str, InitializationOptions] = {}
70+
71+
async def fake_run(
72+
read_stream: Any,
73+
write_stream: Any,
74+
initialization_options: InitializationOptions,
75+
**kwargs: Any,
76+
) -> None:
77+
"""Fake run method capturing the initialization options."""
78+
called_with["init_opts"] = initialization_options
79+
80+
# Create the FastMCP server instance
81+
server = FastMCP("test-server")
82+
83+
# Patch the low-level server.run method to our fake
84+
monkeypatch.setattr(server._mcp_server, "run", fake_run)
85+
86+
# Patch stdio_server to avoid touching real stdin/stdout
87+
@asynccontextmanager
88+
async def fake_stdio_server() -> AsyncIterator[tuple[str, str]]:
89+
yield ("fake_read", "fake_write")
90+
91+
monkeypatch.setattr("mcp.server.fastmcp.server.stdio_server", fake_stdio_server)
92+
93+
# Configure notification options
94+
server.set_notification_options(
95+
prompts_changed=True,
96+
resources_changed=True,
97+
tools_changed=False,
98+
)
99+
100+
# Execute run_stdio_async (uses patched run + stdio_server)
101+
await server.run_stdio_async()
102+
103+
# Verify our fake_run was actually called
104+
assert "init_opts" in called_with, "Expected _mcp_server.run to be called with InitializationOptions"
105+
106+
init_opts: InitializationOptions = called_with["init_opts"]
107+
assert isinstance(init_opts, InitializationOptions)
108+
109+
# Verify the NotificationOptions are reflected correctly in capabilities
110+
caps = init_opts.capabilities
111+
assert caps.prompts is not None and caps.prompts.listChanged is True
112+
assert caps.resources is not None and caps.resources.listChanged is True
113+
assert caps.tools is not None and caps.tools.listChanged is False

0 commit comments

Comments
 (0)