Skip to content

Commit c2e61b6

Browse files
Strengthen Toolsets draft reference tests and docs.
Cover unknown pins on tools/call, omit unregistered membership names, and frame the feature as an incubating SEP reference. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 6979879 commit c2e61b6

3 files changed

Lines changed: 46 additions & 7 deletions

File tree

docs/advanced/toolsets.md

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
# Toolsets
22

3-
**Toolsets** are named, SemVer-versioned, immutable capability surfaces: a fixed
3+
**Toolsets** are named, semantically versioned, immutable capability surfaces: a fixed
44
membership of tool names that a client can discover and pin on `tools/list` and
55
`tools/call`. They address uncontrolled tool-surface expansion when agents discover
66
tools dynamically — a client pinned to `core-ops@1.2.0` never sees tools that only
77
exist in a later Toolset version.
88

9-
This SDK ships Toolsets as the built-in `Toolsets` extension
10-
(`io.modelcontextprotocol/toolsets`), matching the Toolset Versioning SEP draft.
11-
If [Extensions](extensions.md) are new to you, skim that page first.
9+
This page documents the SDK's **draft reference** implementation of the Toolset
10+
Versioning SEP (`Toolsets` / `io.modelcontextprotocol/toolsets`). The surface is
11+
opt-in and may change while the SEP is under review — treat it as incubating, not a
12+
frozen stable API. If [Extensions](extensions.md) are new to you, skim that page first.
1213

1314
## Advertise and publish
1415

@@ -65,7 +66,12 @@ async with Client(mcp, extensions=[advertise(EXTENSION_ID)]) as client:
6566

6667
Omitting `toolset` keeps today's full flat catalog. Calling a non-member under a
6768
pin returns a protocol `MCPError` (`reason: tool_not_in_toolset`), not a tool
68-
`is_error` result.
69+
`is_error` result. Membership names with no registered tool are omitted from a
70+
pinned `tools/list` (the list does not fail).
71+
72+
When `tools/list` pagination is used, membership filtering applies **before**
73+
paging: build the pin's tool sequence, then apply `cursor` / `nextCursor` to that
74+
filtered sequence.
6975

7076
## Cache keys
7177

src/mcp/server/toolsets.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Toolset Versioning extension (`io.modelcontextprotocol/toolsets`).
22
3-
Implements the wire shape from the Toolset Versioning SEP: named, SemVer'd,
3+
Draft reference implementation of the Toolset Versioning SEP: named, semantically versioned,
44
immutable capability surfaces that clients discover via `toolsets/list` and pin
55
on `tools/list` / `tools/call`.
66

tests/server/test_toolsets.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""In-memory tests for the Toolset Versioning extension prototype."""
1+
"""In-memory tests for the Toolset Versioning SEP draft reference implementation."""
22

33
from __future__ import annotations
44

@@ -108,6 +108,39 @@ async def test_unknown_toolset_pin_on_list_raises_mcp_error() -> None:
108108
assert exc_info.value.data["reason"] == "unknown_toolset"
109109

110110

111+
async def test_unknown_toolset_pin_on_call_raises_mcp_error() -> None:
112+
"""Spec: unknown (name, version) on tools/call returns unknown_toolset."""
113+
mcp, _ = _crm_server()
114+
pin = ToolsetRef(name="core-ops", version="9.9.9")
115+
async with Client(mcp, extensions=[advertise(EXTENSION_ID)]) as client:
116+
with pytest.raises(MCPError) as exc_info:
117+
await client.call_tool("search_contacts", {"query": "acme"}, toolset=pin)
118+
assert exc_info.value.code == TOOLSET_ERROR
119+
assert exc_info.value.data["reason"] == "unknown_toolset"
120+
121+
122+
async def test_pinned_tools_list_omits_membership_names_without_registered_tools() -> None:
123+
"""Spec: pinned tools/list omits membership names that have no registered tool."""
124+
toolsets = Toolsets()
125+
mcp = MCPServer("crm", extensions=[toolsets])
126+
127+
@mcp.tool()
128+
def search_contacts(query: str) -> str:
129+
return f"found:{query}"
130+
131+
toolsets.add_toolset(
132+
name="core-ops",
133+
version="1.0.0",
134+
status="stable",
135+
tools=["search_contacts", "ghost_tool_not_registered"],
136+
)
137+
pin = ToolsetRef(name="core-ops", version="1.0.0")
138+
139+
async with Client(mcp, extensions=[advertise(EXTENSION_ID)]) as client:
140+
result = await client.list_tools(toolset=pin)
141+
assert [t.name for t in result.tools] == ["search_contacts"]
142+
143+
111144
async def test_pinned_call_rejects_non_member_and_allows_member() -> None:
112145
"""Spec: tools/call under a pin rejects non-members and runs members."""
113146
mcp, _ = _crm_server()

0 commit comments

Comments
 (0)