Skip to content

Client sampling and roots capabilities set to None if not implemented #802

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions src/mcp/client/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,12 +116,18 @@ def __init__(
self._message_handler = message_handler or _default_message_handler

async def initialize(self) -> types.InitializeResult:
sampling = types.SamplingCapability()
roots = types.RootsCapability(
sampling = (
types.SamplingCapability()
if self._sampling_callback is not _default_sampling_callback
else None
)
roots = (
# TODO: Should this be based on whether we
# _will_ send notifications, or only whether
# they're supported?
listChanged=True,
types.RootsCapability(listChanged=True)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'm curious - does this entail that any client that supports roots, or list_roots, supports roots.listChanged?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe worth putting the comment back, sorry.

The client can send list changes, as the code is there to trigger that notification, so it's supported.
But the spec says "listChanged indicates whether the client will emit notifications when the list of roots changes."
We do need to address this, but a separate PR would be probably be better

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add the comment back

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know if I follow...
So I read the comment as asking - should the listChanged=True be included if and only if the client WILL send a notification when the roots list is changed, or that it MAY (i.e., it "supports it"). And I think I even said roughly "this is answered in the spec":

listChanged indicates whether the client will emit notifications when the list of roots changes.

So if that's the meaning of the question/comment, I think it's answered by the spec. My question was - if a client SUPPORTS ROOTS (e.g. they support "roots/list"), does it follow that they support "roots/list_changed"?

That's MY question, and it seems to me that the spec implies NO, otherwise (a) why would this be need to be announced in the capabilities; (b) why would the spec talk about "whether the client will emit notifications" if its non-optional.

Cuz the client could (a) not support roots; (b) support roots, and therefore support "roots/list", but not support "notifications/roots/list_changed" (c) support roots, "roots/list" and listChanged.

In the spec it sort of visually implies that SUPPORTS ROOTS -> SUPPORTS ROOTS/LIST -> SUPPORTS NOTIFICATIONS/ROOTS/LIST_CHANGED. But I don't believe this is actually stated anywhere.

So my case is - a client may or may not support roots full stop. If they support roots, then they must support "roots/list" - but that's not something that's declared in the capabilities - but I don't see any language that says that any client that SUPPORTS ROOTS also supports roots/list_changed.

So my reading of: "Clients that support roots MUST declare the roots capability during initialization" is that the existence of the "roots" key indicates support for roots (and therefore list-roots), and the value for roots.listChanged indicates support for list_CHANGED. So the capabilities object could have:
(1) no roots support:

"capabilities": {
      "sampling": {}
    },

(2) supports roots (and "roots/list") but not listChanged:

"capabilities": {
      "roots": {
        "listChanged": false
      },
      "sampling": {}
    },

(3) supports roots, "roots/list" and listChanged:

"capabilities": {
      "roots": {
        "listChanged": true
      },
      "sampling": {}
    },

I think this is consistent with the schema.ts:

export interface ClientCapabilities {
  /**
   * Experimental, non-standard capabilities that the client supports.
   */
  experimental?: { [key: string]: object };
  /**
   * Present if the client supports listing roots.
   */
  roots?: {
    /**
     * Whether the client supports notifications for changes to the roots list.
     */
    listChanged?: boolean;
  };
  /**
   * Present if the client supports sampling from an LLM.
   */
  sampling?: object;
}

capabilities.roots is optional and is present if the client supports listing roots. capabilities.roots.listChanged is optional and is true if the client support "roots/list_changed" and false if the client supports "roots/list" but not "roots/list_changed".

FWIW, I think this is slightly non-optimal and that listChanged?: boolean should be listChanged: boolean, the "meaning" overal being: if you support roots at all, then you must support "roots/list" (hence, this does not need to be announced), but it's still yes/no if you support "roots/list_changed" (hence that must be announced).

The current structure seems to allow:

...
roots: {}
...

which I guess is equivalent to:

...
roots: {
  listChanged: false
}
...

SO - on my reading, there are three states - (a) no roots (omit the key), (b) "roots/list" support but not "roots/list_changed" (include key and object value and either omit roots.listChanged or set it to false, and (c) supports "roots/list_changed" (include roots.listChanged: true) - in case (c), the client MUST send "notifications/roots/list_changed" when list changes. This also comports with this:

Category | Capability | Description
-------- -------- -------
Client | roots | Ability to provide filesystem roots

Meaning the presence of the roots key is optional and indicates, if present that the client (at least) supports "roots/list".

THEN, there is the further announcement of whether they ALSO supports "roots/list_changed", as in:

Capability objects can describe sub-capabilities like:
 * `listChanged`: Support for list change notifications...

Pulling it together, the comment is asking: if the client SUPPORTS "roots/list_changed", does it follow that they MAY or that they MUST actually send the notifications?
My question was: if a client SUPPORTS roots, does it follow that they support listChanged - (a) does it follow given the logic in this PR? (b) is that what the spec declares? For (b) I say "no", so if the PR entails "yes", then I believe it is against the spec.

if self._list_roots_callback is not _default_list_roots_callback
else None
)

result = await self.send_request(
Expand Down
2 changes: 1 addition & 1 deletion src/mcp/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ class RootsCapability(BaseModel):


class SamplingCapability(BaseModel):
"""Capability for logging operations."""
"""Capability for sampling operations."""

model_config = ConfigDict(extra="allow")

Expand Down
167 changes: 167 additions & 0 deletions tests/client/test_session.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
from typing import Any

import anyio
import pytest

import mcp.types as types
from mcp.client.session import DEFAULT_CLIENT_INFO, ClientSession
from mcp.shared.context import RequestContext
from mcp.shared.message import SessionMessage
from mcp.shared.session import RequestResponder
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS
Expand Down Expand Up @@ -380,3 +383,167 @@ async def mock_server():
# Should raise RuntimeError for unsupported version
with pytest.raises(RuntimeError, match="Unsupported protocol version"):
await session.initialize()


@pytest.mark.anyio
async def test_client_capabilities_default():
"""Test that client capabilities are properly set with default callbacks"""
client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[
SessionMessage
](1)
server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[
SessionMessage
](1)

received_capabilities = None

async def mock_server():
nonlocal received_capabilities

session_message = await client_to_server_receive.receive()
jsonrpc_request = session_message.message
assert isinstance(jsonrpc_request.root, JSONRPCRequest)
request = ClientRequest.model_validate(
jsonrpc_request.model_dump(by_alias=True, mode="json", exclude_none=True)
)
assert isinstance(request.root, InitializeRequest)
received_capabilities = request.root.params.capabilities

result = ServerResult(
InitializeResult(
protocolVersion=LATEST_PROTOCOL_VERSION,
capabilities=ServerCapabilities(),
serverInfo=Implementation(name="mock-server", version="0.1.0"),
)
)

async with server_to_client_send:
await server_to_client_send.send(
SessionMessage(
JSONRPCMessage(
JSONRPCResponse(
jsonrpc="2.0",
id=jsonrpc_request.root.id,
result=result.model_dump(
by_alias=True, mode="json", exclude_none=True
),
)
)
)
)
# Receive initialized notification
await client_to_server_receive.receive()

async with (
ClientSession(
server_to_client_receive,
client_to_server_send,
) as session,
anyio.create_task_group() as tg,
client_to_server_send,
client_to_server_receive,
server_to_client_send,
server_to_client_receive,
):
tg.start_soon(mock_server)
await session.initialize()

# Assert that capabilities are properly set with defaults
assert received_capabilities is not None
assert received_capabilities.sampling is None # No custom sampling callback
assert received_capabilities.roots is None # No custom list_roots callback


@pytest.mark.anyio
async def test_client_capabilities_with_custom_callbacks():
"""Test that client capabilities are properly set with custom callbacks"""
client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[
SessionMessage
](1)
server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[
SessionMessage
](1)

received_capabilities = None

async def custom_sampling_callback(
context: RequestContext["ClientSession", Any],
params: types.CreateMessageRequestParams,
) -> types.CreateMessageResult | types.ErrorData:
return types.CreateMessageResult(
role="assistant",
content=types.TextContent(type="text", text="test"),
model="test-model",
)

async def custom_list_roots_callback(
context: RequestContext["ClientSession", Any],
) -> types.ListRootsResult | types.ErrorData:
return types.ListRootsResult(roots=[])

async def mock_server():
nonlocal received_capabilities

session_message = await client_to_server_receive.receive()
jsonrpc_request = session_message.message
assert isinstance(jsonrpc_request.root, JSONRPCRequest)
request = ClientRequest.model_validate(
jsonrpc_request.model_dump(by_alias=True, mode="json", exclude_none=True)
)
assert isinstance(request.root, InitializeRequest)
received_capabilities = request.root.params.capabilities

result = ServerResult(
InitializeResult(
protocolVersion=LATEST_PROTOCOL_VERSION,
capabilities=ServerCapabilities(),
serverInfo=Implementation(name="mock-server", version="0.1.0"),
)
)

async with server_to_client_send:
await server_to_client_send.send(
SessionMessage(
JSONRPCMessage(
JSONRPCResponse(
jsonrpc="2.0",
id=jsonrpc_request.root.id,
result=result.model_dump(
by_alias=True, mode="json", exclude_none=True
),
)
)
)
)
# Receive initialized notification
await client_to_server_receive.receive()

async with (
ClientSession(
server_to_client_receive,
client_to_server_send,
sampling_callback=custom_sampling_callback,
list_roots_callback=custom_list_roots_callback,
) as session,
anyio.create_task_group() as tg,
client_to_server_send,
client_to_server_receive,
server_to_client_send,
server_to_client_receive,
):
tg.start_soon(mock_server)
await session.initialize()

# Assert that capabilities are properly set with custom callbacks
assert received_capabilities is not None
assert (
received_capabilities.sampling is not None
) # Custom sampling callback provided
assert isinstance(received_capabilities.sampling, types.SamplingCapability)
assert (
received_capabilities.roots is not None
) # Custom list_roots callback provided
assert isinstance(received_capabilities.roots, types.RootsCapability)
assert (
received_capabilities.roots.listChanged is True
) # Should be True for custom callback
Loading