Skip to content

Commit 3e2fb4f

Browse files
BabyChrist666claude
andcommitted
feat: add public API for runtime handler registration/deregistration
Add add_request_handler(), remove_request_handler(), add_notification_handler(), remove_notification_handler(), and has_handler() as public methods on the low-level Server class. This enables frameworks and advanced use cases to register handlers for protocol extensions or custom methods after server construction, and to remove or replace handlers dynamically. Refactors ExperimentalHandlers to use the new public API instead of receiving private method references, validating the API with its first internal consumer. Fixes #2135 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0fe16dd commit 3e2fb4f

File tree

3 files changed

+184
-26
lines changed

3 files changed

+184
-26
lines changed

src/mcp/server/lowlevel/experimental.py

Lines changed: 23 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
import logging
99
from collections.abc import Awaitable, Callable
10-
from typing import Any, Generic
10+
from typing import TYPE_CHECKING, Any, Generic
1111

1212
from typing_extensions import TypeVar
1313

@@ -38,6 +38,9 @@
3838
TasksToolsCapability,
3939
)
4040

41+
if TYPE_CHECKING:
42+
from mcp.server.lowlevel.server import Server
43+
4144
logger = logging.getLogger(__name__)
4245

4346
LifespanResultT = TypeVar("LifespanResultT", default=Any)
@@ -51,13 +54,9 @@ class ExperimentalHandlers(Generic[LifespanResultT]):
5154

5255
def __init__(
5356
self,
54-
add_request_handler: Callable[
55-
[str, Callable[[ServerRequestContext[LifespanResultT], Any], Awaitable[Any]]], None
56-
],
57-
has_handler: Callable[[str], bool],
57+
server: Server[LifespanResultT, Any],
5858
) -> None:
59-
self._add_request_handler = add_request_handler
60-
self._has_handler = has_handler
59+
self._server = server
6160
self._task_support: TaskSupport | None = None
6261

6362
@property
@@ -67,13 +66,15 @@ def task_support(self) -> TaskSupport | None:
6766

6867
def update_capabilities(self, capabilities: ServerCapabilities) -> None:
6968
# Only add tasks capability if handlers are registered
70-
if not any(self._has_handler(method) for method in ["tasks/get", "tasks/list", "tasks/cancel", "tasks/result"]):
69+
if not any(
70+
self._server.has_handler(method) for method in ["tasks/get", "tasks/list", "tasks/cancel", "tasks/result"]
71+
):
7172
return
7273

7374
capabilities.tasks = ServerTasksCapability()
74-
if self._has_handler("tasks/list"):
75+
if self._server.has_handler("tasks/list"):
7576
capabilities.tasks.list = TasksListCapability()
76-
if self._has_handler("tasks/cancel"):
77+
if self._server.has_handler("tasks/cancel"):
7778
capabilities.tasks.cancel = TasksCancelCapability()
7879

7980
capabilities.tasks.requests = ServerTasksRequestsCapability(
@@ -145,16 +146,16 @@ def enable_tasks(
145146

146147
# Register user-provided handlers
147148
if on_get_task is not None:
148-
self._add_request_handler("tasks/get", on_get_task)
149+
self._server.add_request_handler("tasks/get", on_get_task)
149150
if on_task_result is not None:
150-
self._add_request_handler("tasks/result", on_task_result)
151+
self._server.add_request_handler("tasks/result", on_task_result)
151152
if on_list_tasks is not None:
152-
self._add_request_handler("tasks/list", on_list_tasks)
153+
self._server.add_request_handler("tasks/list", on_list_tasks)
153154
if on_cancel_task is not None:
154-
self._add_request_handler("tasks/cancel", on_cancel_task)
155+
self._server.add_request_handler("tasks/cancel", on_cancel_task)
155156

156157
# Fill in defaults for any not provided
157-
if not self._has_handler("tasks/get"):
158+
if not self._server.has_handler("tasks/get"):
158159

159160
async def _default_get_task(
160161
ctx: ServerRequestContext[LifespanResultT], params: GetTaskRequestParams
@@ -172,9 +173,9 @@ async def _default_get_task(
172173
poll_interval=task.poll_interval,
173174
)
174175

175-
self._add_request_handler("tasks/get", _default_get_task)
176+
self._server.add_request_handler("tasks/get", _default_get_task)
176177

177-
if not self._has_handler("tasks/result"):
178+
if not self._server.has_handler("tasks/result"):
178179

179180
async def _default_get_task_result(
180181
ctx: ServerRequestContext[LifespanResultT], params: GetTaskPayloadRequestParams
@@ -184,9 +185,9 @@ async def _default_get_task_result(
184185
result = await task_support.handler.handle(req, ctx.session, ctx.request_id)
185186
return result
186187

187-
self._add_request_handler("tasks/result", _default_get_task_result)
188+
self._server.add_request_handler("tasks/result", _default_get_task_result)
188189

189-
if not self._has_handler("tasks/list"):
190+
if not self._server.has_handler("tasks/list"):
190191

191192
async def _default_list_tasks(
192193
ctx: ServerRequestContext[LifespanResultT], params: PaginatedRequestParams | None
@@ -195,16 +196,16 @@ async def _default_list_tasks(
195196
tasks, next_cursor = await task_support.store.list_tasks(cursor)
196197
return ListTasksResult(tasks=tasks, next_cursor=next_cursor)
197198

198-
self._add_request_handler("tasks/list", _default_list_tasks)
199+
self._server.add_request_handler("tasks/list", _default_list_tasks)
199200

200-
if not self._has_handler("tasks/cancel"):
201+
if not self._server.has_handler("tasks/cancel"):
201202

202203
async def _default_cancel_task(
203204
ctx: ServerRequestContext[LifespanResultT], params: CancelTaskRequestParams
204205
) -> CancelTaskResult:
205206
result = await cancel_task(task_support.store, params.task_id)
206207
return result
207208

208-
self._add_request_handler("tasks/cancel", _default_cancel_task)
209+
self._server.add_request_handler("tasks/cancel", _default_cancel_task)
209210

210211
return task_support

src/mcp/server/lowlevel/server.py

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,72 @@ def _has_handler(self, method: str) -> bool:
246246
"""Check if a handler is registered for the given method."""
247247
return method in self._request_handlers or method in self._notification_handlers
248248

249+
def add_request_handler(
250+
self,
251+
method: str,
252+
handler: Callable[[ServerRequestContext[LifespanResultT], Any], Awaitable[Any]],
253+
) -> None:
254+
"""Register a request handler for the given method.
255+
256+
If a handler is already registered for this method, it will be replaced.
257+
258+
Args:
259+
method: The JSON-RPC method name (e.g., "tools/list", "myextension/query").
260+
handler: An async callable that takes (ServerRequestContext, params) and
261+
returns the result.
262+
"""
263+
self._request_handlers[method] = handler
264+
265+
def remove_request_handler(self, method: str) -> None:
266+
"""Remove the request handler for the given method.
267+
268+
Args:
269+
method: The JSON-RPC method name to deregister.
270+
271+
Raises:
272+
KeyError: If no handler is registered for this method.
273+
"""
274+
del self._request_handlers[method]
275+
276+
def add_notification_handler(
277+
self,
278+
method: str,
279+
handler: Callable[[ServerRequestContext[LifespanResultT], Any], Awaitable[None]],
280+
) -> None:
281+
"""Register a notification handler for the given method.
282+
283+
If a handler is already registered for this method, it will be replaced.
284+
285+
Args:
286+
method: The JSON-RPC notification method name
287+
(e.g., "notifications/progress").
288+
handler: An async callable that takes (ServerRequestContext, params) and
289+
returns None.
290+
"""
291+
self._notification_handlers[method] = handler
292+
293+
def remove_notification_handler(self, method: str) -> None:
294+
"""Remove the notification handler for the given method.
295+
296+
Args:
297+
method: The JSON-RPC notification method name to deregister.
298+
299+
Raises:
300+
KeyError: If no handler is registered for this method.
301+
"""
302+
del self._notification_handlers[method]
303+
304+
def has_handler(self, method: str) -> bool:
305+
"""Check if a handler is registered for the given request or notification method.
306+
307+
Args:
308+
method: The JSON-RPC method name to check.
309+
310+
Returns:
311+
True if a handler is registered, False otherwise.
312+
"""
313+
return method in self._request_handlers or method in self._notification_handlers
314+
249315
# TODO: Rethink capabilities API. Currently capabilities are derived from registered
250316
# handlers but require NotificationOptions to be passed externally for list_changed
251317
# flags, and experimental_capabilities as a separate dict. Consider deriving capabilities
@@ -336,10 +402,7 @@ def experimental(self) -> ExperimentalHandlers[LifespanResultT]:
336402

337403
# We create this inline so we only add these capabilities _if_ they're actually used
338404
if self._experimental_handlers is None:
339-
self._experimental_handlers = ExperimentalHandlers(
340-
add_request_handler=self._add_request_handler,
341-
has_handler=self._has_handler,
342-
)
405+
self._experimental_handlers = ExperimentalHandlers(server=self)
343406
return self._experimental_handlers
344407

345408
@property
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""Tests for public handler registration/deregistration API on low-level Server."""
2+
3+
import pytest
4+
5+
from mcp.server.lowlevel.server import Server
6+
7+
8+
@pytest.fixture
9+
def server():
10+
return Server(name="test-server")
11+
12+
13+
async def _dummy_request_handler(ctx, params):
14+
return {"result": "ok"}
15+
16+
17+
async def _dummy_notification_handler(ctx, params):
18+
pass
19+
20+
21+
class TestAddRequestHandler:
22+
def test_add_request_handler(self, server):
23+
server.add_request_handler("custom/method", _dummy_request_handler)
24+
assert server.has_handler("custom/method")
25+
26+
def test_add_request_handler_replaces_existing(self, server):
27+
async def handler_a(ctx, params):
28+
return "a"
29+
30+
async def handler_b(ctx, params):
31+
return "b"
32+
33+
server.add_request_handler("custom/method", handler_a)
34+
server.add_request_handler("custom/method", handler_b)
35+
# The second handler should replace the first
36+
assert server._request_handlers["custom/method"] is handler_b
37+
38+
39+
class TestRemoveRequestHandler:
40+
def test_remove_request_handler(self, server):
41+
server.add_request_handler("custom/method", _dummy_request_handler)
42+
assert server.has_handler("custom/method")
43+
server.remove_request_handler("custom/method")
44+
assert not server.has_handler("custom/method")
45+
46+
def test_remove_request_handler_not_found(self, server):
47+
with pytest.raises(KeyError):
48+
server.remove_request_handler("nonexistent/method")
49+
50+
51+
class TestAddNotificationHandler:
52+
def test_add_notification_handler(self, server):
53+
server.add_notification_handler("custom/notify", _dummy_notification_handler)
54+
assert server.has_handler("custom/notify")
55+
56+
def test_add_notification_handler_replaces_existing(self, server):
57+
async def handler_a(ctx, params):
58+
pass
59+
60+
async def handler_b(ctx, params):
61+
pass
62+
63+
server.add_notification_handler("custom/notify", handler_a)
64+
server.add_notification_handler("custom/notify", handler_b)
65+
assert server._notification_handlers["custom/notify"] is handler_b
66+
67+
68+
class TestRemoveNotificationHandler:
69+
def test_remove_notification_handler(self, server):
70+
server.add_notification_handler("custom/notify", _dummy_notification_handler)
71+
assert server.has_handler("custom/notify")
72+
server.remove_notification_handler("custom/notify")
73+
assert not server.has_handler("custom/notify")
74+
75+
def test_remove_notification_handler_not_found(self, server):
76+
with pytest.raises(KeyError):
77+
server.remove_notification_handler("nonexistent/notify")
78+
79+
80+
class TestHasHandler:
81+
def test_has_handler_request(self, server):
82+
server.add_request_handler("custom/method", _dummy_request_handler)
83+
assert server.has_handler("custom/method")
84+
85+
def test_has_handler_notification(self, server):
86+
server.add_notification_handler("custom/notify", _dummy_notification_handler)
87+
assert server.has_handler("custom/notify")
88+
89+
def test_has_handler_unregistered(self, server):
90+
assert not server.has_handler("nonexistent/method")
91+
92+
def test_has_handler_default_ping(self, server):
93+
"""The ping handler is registered by default."""
94+
assert server.has_handler("ping")

0 commit comments

Comments
 (0)