Skip to content

Commit 6979879

Browse files
Add Toolset Versioning extension prototype.
Implements io.modelcontextprotocol/toolsets with toolsets/list, pin filtering on tools/list and tools/call, client helpers, docs, and tests. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 6419ae0 commit 6979879

9 files changed

Lines changed: 593 additions & 9 deletions

File tree

docs/advanced/toolsets.md

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# Toolsets
2+
3+
**Toolsets** are named, SemVer-versioned, immutable capability surfaces: a fixed
4+
membership of tool names that a client can discover and pin on `tools/list` and
5+
`tools/call`. They address uncontrolled tool-surface expansion when agents discover
6+
tools dynamically — a client pinned to `core-ops@1.2.0` never sees tools that only
7+
exist in a later Toolset version.
8+
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.
12+
13+
## Advertise and publish
14+
15+
```python
16+
from mcp.server.mcpserver import MCPServer
17+
from mcp.server.toolsets import Toolsets
18+
19+
toolsets = Toolsets()
20+
mcp = MCPServer("crm", extensions=[toolsets])
21+
22+
@mcp.tool()
23+
def search_contacts(query: str) -> str:
24+
return f"found:{query}"
25+
26+
@mcp.tool()
27+
def analyze_report(report_id: str) -> str:
28+
return f"report:{report_id}"
29+
30+
toolsets.add_toolset(
31+
name="core-ops",
32+
version="1.2.0",
33+
status="stable",
34+
tools=["search_contacts"],
35+
)
36+
toolsets.add_toolset(
37+
name="core-ops",
38+
version="1.3.0",
39+
status="stable",
40+
tools=["search_contacts", "analyze_report"],
41+
)
42+
```
43+
44+
`(name, version)` is immutable once published. Register ordinary tools with
45+
`@mcp.tool()`; Toolsets only declare membership by name.
46+
47+
## Client pin
48+
49+
Clients that pin Toolsets must advertise the extension, then pass the same
50+
`ToolsetRef` on list and call:
51+
52+
```python
53+
from mcp import Client
54+
from mcp.client import advertise
55+
from mcp.server.toolsets import EXTENSION_ID
56+
from mcp_types import ToolsetRef
57+
58+
pin = ToolsetRef(name="core-ops", version="1.2.0")
59+
60+
async with Client(mcp, extensions=[advertise(EXTENSION_ID)]) as client:
61+
published = await client.list_toolsets()
62+
tools = await client.list_tools(toolset=pin) # no analyze_report
63+
result = await client.call_tool("search_contacts", {"query": "acme"}, toolset=pin)
64+
```
65+
66+
Omitting `toolset` keeps today's full flat catalog. Calling a non-member under a
67+
pin returns a protocol `MCPError` (`reason: tool_not_in_toolset`), not a tool
68+
`is_error` result.
69+
70+
## Cache keys
71+
72+
Pinned `tools/list` responses are cached separately from the unpinned catalog
73+
(and from other pins). Distinct pins never reuse an unpinned cache entry.

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ nav:
6767
- Middleware: advanced/middleware.md
6868
- Extensions: advanced/extensions.md
6969
- MCP Apps: advanced/apps.md
70+
- Toolsets: advanced/toolsets.md
7071
- Troubleshooting: troubleshooting.md
7172
- Migration Guide: migration.md
7273
- API Reference: api/

src/mcp-types/mcp_types/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,11 @@
9393
ListRootsResult,
9494
ListTasksRequest,
9595
ListTasksResult,
96+
ListToolsetsRequest,
97+
ListToolsetsRequestParams,
98+
ListToolsetsResult,
9699
ListToolsRequest,
100+
ListToolsRequestParams,
97101
ListToolsResult,
98102
LoggingCapability,
99103
LoggingLevel,
@@ -184,6 +188,9 @@
184188
ToolListChangedNotification,
185189
ToolResultContent,
186190
ToolsCapability,
191+
Toolset,
192+
ToolsetRef,
193+
ToolsetStatus,
187194
ToolUseContent,
188195
UnsubscribeRequest,
189196
UnsubscribeRequestParams,
@@ -336,6 +343,13 @@
336343
"CancelTaskRequest",
337344
"CancelTaskRequestParams",
338345
"CreateMessageRequest",
346+
"ListToolsetsRequestParams",
347+
"ListToolsetsResult",
348+
"ListToolsetsRequest",
349+
"ListToolsRequestParams",
350+
"Toolset",
351+
"ToolsetRef",
352+
"ToolsetStatus",
339353
"CreateMessageRequestParams",
340354
"DiscoverRequest",
341355
"ElicitRequest",

src/mcp-types/mcp_types/_types.py

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,69 @@ class PaginatedResult(Result):
188188
"""
189189

190190

191+
ToolsetStatus = Literal["stable", "deprecated", "experimental"]
192+
"""Lifecycle status of a published Toolset version (toolsets extension)."""
193+
194+
195+
class ToolsetRef(MCPModel):
196+
"""Reference to a published Toolset `(name, version)` pin."""
197+
198+
name: str
199+
"""Toolset identifier within the server."""
200+
version: str
201+
"""Exact Toolset SemVer. Ranges are not supported in v1."""
202+
203+
204+
class Toolset(MCPModel):
205+
"""A named, versioned, immutable capability surface (toolsets extension)."""
206+
207+
name: str
208+
"""Toolset identifier within the server. Unique together with `version`."""
209+
version: str
210+
"""Semantic Version 2.0.0 of this Toolset publication."""
211+
title: str | None = None
212+
"""Optional human-readable display name."""
213+
description: str | None = None
214+
"""Optional description of the capability surface."""
215+
status: ToolsetStatus = "stable"
216+
"""Lifecycle status of this Toolset version."""
217+
tools: list[str]
218+
"""Fixed membership: tool names included in this surface."""
219+
deprecation_date: str | None = None
220+
"""Optional ISO-8601 date after which clients SHOULD stop selecting this version."""
221+
222+
223+
class ListToolsetsRequestParams(RequestParams):
224+
"""Parameters for `toolsets/list`."""
225+
226+
name: str | None = None
227+
"""If set, only Toolsets with this name."""
228+
status: ToolsetStatus | None = None
229+
"""If set, only Toolsets with this status."""
230+
cursor: str | None = None
231+
"""Opaque pagination cursor."""
232+
233+
234+
class ListToolsetsResult(PaginatedResult):
235+
"""Result of `toolsets/list`."""
236+
237+
toolsets: list[Toolset]
238+
239+
240+
class ListToolsetsRequest(Request[ListToolsetsRequestParams, Literal["toolsets/list"]]):
241+
"""Discover published Toolsets on a server that supports the toolsets extension."""
242+
243+
method: Literal["toolsets/list"] = "toolsets/list"
244+
params: ListToolsetsRequestParams
245+
246+
247+
class ListToolsRequestParams(PaginatedRequestParams):
248+
"""Parameters for `tools/list`, including an optional Toolset pin."""
249+
250+
toolset: ToolsetRef | None = None
251+
"""When set, restrict the listing to this Toolset's membership."""
252+
253+
191254
class CacheableResult(Result):
192255
"""Base class for results that carry client-side caching directives (2026-07-28).
193256
@@ -1351,10 +1414,12 @@ class PromptListChangedNotification(
13511414
params: NotificationParams | None = None
13521415

13531416

1354-
class ListToolsRequest(PaginatedRequest[Literal["tools/list"]]):
1417+
class ListToolsRequest(Request[ListToolsRequestParams | None, Literal["tools/list"]]):
13551418
"""Sent from the client to request a list of tools the server has."""
13561419

13571420
method: Literal["tools/list"] = "tools/list"
1421+
params: ListToolsRequestParams | None = None
1422+
"""Pagination + optional Toolset pin. Session layer materializes `_meta` on modern versions."""
13581423

13591424

13601425
class ToolAnnotations(MCPModel):
@@ -1446,6 +1511,8 @@ class CallToolRequestParams(InputResponseRequestParams):
14461511
arguments: dict[str, Any] | None = None
14471512
task: TaskMetadata | None = None
14481513
"""If specified, the caller requests task-augmented execution (2025-11-25 only)."""
1514+
toolset: ToolsetRef | None = None
1515+
"""When set, the tool MUST be a member of this Toolset (toolsets extension)."""
14491516

14501517

14511518
class CallToolRequest(Request[CallToolRequestParams, Literal["tools/call"]]):

src/mcp/client/client.py

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@
2929
ListPromptsResult,
3030
ListResourcesResult,
3131
ListResourceTemplatesResult,
32+
ListToolsetsRequestParams,
33+
ListToolsetsResult,
34+
ListToolsRequestParams,
3235
ListToolsResult,
3336
LoggingLevel,
3437
PaginatedRequestParams,
@@ -38,6 +41,7 @@
3841
ResourceTemplateReference,
3942
Result,
4043
ServerCapabilities,
44+
ToolsetRef,
4145
)
4246
from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS, MODERN_PROTOCOL_VERSIONS
4347
from typing_extensions import deprecated
@@ -543,10 +547,12 @@ async def _cached_fetch(
543547
cache_mode: CacheMode,
544548
send: Callable[[], Awaitable[_CacheableT]],
545549
absorb: Callable[[_CacheableT], _CacheableT] | None = None,
550+
cache_key: str = "",
546551
) -> _CacheableT:
547552
"""Serve one of the four list verbs through the response cache.
548553
549554
`absorb` (tools/list only) re-applies session-side derived state to a served cache hit.
555+
`cache_key` disambiguates variants of the same method (e.g. Toolset pins).
550556
"""
551557
cache = self._response_cache
552558
if cache is None or cache_mode == "bypass":
@@ -564,13 +570,13 @@ async def _cached_fetch(
564570
if e.code == INVALID_PARAMS:
565571
await cache.evict_method(method)
566572
raise
567-
if cache_mode == "use" and (hit := await cache.read(method, "")) is not None:
573+
if cache_mode == "use" and (hit := await cache.read(method, cache_key)) is not None:
568574
# The hit is a private deep copy, so absorption may mutate it freely.
569575
served = cast(_CacheableT, hit)
570576
return served if absorb is None else absorb(served)
571-
gen = cache.capture(method, "")
577+
gen = cache.capture(method, cache_key)
572578
result = await send()
573-
await cache.write(method, "", result, gen, cache_mode)
579+
await cache.write(method, cache_key, result, gen, cache_mode)
574580
return result
575581

576582
async def list_resources(
@@ -742,6 +748,7 @@ async def call_tool(
742748
input_responses: InputResponses | None = None,
743749
request_state: str | None = None,
744750
meta: RequestParamsMeta | None = None,
751+
toolset: ToolsetRef | None = None,
745752
) -> CallToolResult:
746753
"""Call a tool on the server.
747754
@@ -768,6 +775,7 @@ async def call_tool(
768775
resuming from a persisted `InputRequiredResult`).
769776
request_state: Opaque state to seed the first call with.
770777
meta: Additional metadata for the request.
778+
toolset: Optional Toolset pin (toolsets extension).
771779
772780
Returns:
773781
The tool result.
@@ -788,6 +796,7 @@ async def retry(r: InputResponses | None, s: str | None) -> CallToolResult | Inp
788796
input_responses=r,
789797
request_state=s,
790798
meta=meta,
799+
toolset=toolset,
791800
allow_input_required=True,
792801
# Input rounds resolve before a claimed result, so a claim may end any round.
793802
allow_claimed=True,
@@ -910,15 +919,27 @@ async def list_tools(
910919
*,
911920
cursor: str | None = None,
912921
meta: RequestParamsMeta | None = None,
922+
toolset: ToolsetRef | None = None,
913923
cache_mode: CacheMode = "use",
914924
) -> ListToolsResult:
915-
"""List available tools from the server."""
925+
"""List available tools from the server.
926+
927+
Args:
928+
cursor: Pagination cursor.
929+
meta: Request `_meta`.
930+
toolset: Optional Toolset pin (toolsets extension).
931+
cache_mode: Response-cache behaviour.
932+
"""
933+
key = "" if toolset is None else f"{toolset.name}@{toolset.version}"
916934
return await self._cached_fetch(
917935
"tools/list",
918936
cursor=cursor,
919937
meta=meta,
920938
cache_mode=cache_mode,
921-
send=lambda: self.session.list_tools(params=PaginatedRequestParams(cursor=cursor, _meta=meta)),
939+
cache_key=key,
940+
send=lambda: self.session.list_tools(
941+
params=ListToolsRequestParams(cursor=cursor, toolset=toolset, _meta=meta)
942+
),
922943
# A cache hit skips session.list_tools, so the session re-absorbs the served
923944
# listing to rebuild its derived per-tool state. Hits are cursorless, but a
924945
# cached page 1 can carry next_cursor - never prune on a partial listing.
@@ -927,6 +948,16 @@ async def list_tools(
927948
),
928949
)
929950

951+
async def list_toolsets(
952+
self,
953+
*,
954+
name: str | None = None,
955+
status: types.ToolsetStatus | None = None,
956+
meta: RequestParamsMeta | None = None,
957+
) -> ListToolsetsResult:
958+
"""List published Toolsets (toolsets extension)."""
959+
return await self.session.list_toolsets(params=ListToolsetsRequestParams(name=name, status=status, _meta=meta))
960+
930961
@deprecated("The roots capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning)
931962
async def send_roots_list_changed(self) -> None:
932963
"""Send a notification that the roots list has changed."""

0 commit comments

Comments
 (0)