Skip to content

Commit 3f45540

Browse files
committed
Expose typed client extension APIs and tighten Skills validation
1 parent 01e5234 commit 3f45540

13 files changed

Lines changed: 157 additions & 55 deletions

File tree

‎docs/advanced/extensions.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,30 @@ that did not declare it (error -32021), and a claimed shape from a server that
190190
skips the gate fails validation, exactly as the spec requires for an
191191
unrecognized `resultType`. Off by default, on both ends of the wire.
192192

193+
An extension can also expose methods of its own. Pass its type to `client.extension()`
194+
after connecting to get the API bound to that connection:
195+
196+
```python
197+
import anyio
198+
199+
from mcp import Client
200+
from mcp.client.skills import Skills
201+
202+
203+
async def main() -> None:
204+
async with Client("http://localhost:8000/mcp", extensions=[Skills()]) as client:
205+
for skill in await client.extension(Skills).list_skills():
206+
print(skill.uri)
207+
208+
209+
if __name__ == "__main__":
210+
anyio.run(main)
211+
```
212+
213+
`client.extension(Skills)` returns the type produced by `Skills.bind(session)`. Your own
214+
extension can define the same hook. The client raises `ValueError` when the type was not
215+
registered and `RuntimeError` when it is not connected.
216+
193217
To advertise an identifier with **no** client-side behaviour (the server gates on
194218
the capability, the client does nothing, as in the search client above), use
195219
`advertise()`:

‎docs/advanced/skills.md‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,14 +49,14 @@ And that's it. `Skills(list_skills=..., get_skill=...)` is all a server needs;
4949
## Fetching a skill
5050

5151
On the client side, `Skills` is a [`ClientExtension`](extensions.md). You register it the same way
52-
you register any other one — by passing it to `Client(extensions=[...])` — and then call `bind` to
52+
you register any other one — by passing it to `Client(extensions=[...])` — and then call `extension` to
5353
get the verbs tied to that connection:
5454

5555
```python title="client.py" hl_lines="9-11"
5656
--8<-- "docs_src/skills/tutorial001_client.py"
5757
```
5858

59-
`skills.bind(client)` hands you a `BoundSkills`, and its methods are the SEP-2640 verbs:
59+
`client.extension(Skills)` hands you a `BoundSkills`, and its methods are the SEP-2640 verbs:
6060

6161
* `list_skills` and `read_directory` follow `nextCursor` to completion, so a single call gives you
6262
every page's skills or resources.
@@ -124,8 +124,9 @@ setting.
124124
In protocol version `2026-07-28` and later, `skills/list` and `skills/get` results carry the base
125125
protocol's caching fields, [`ttlMs` and `cacheScope`](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549) —
126126
the same freshness hint `tools/list`, `resources/list`, and `resources/read` carry. `Skills` fills
127-
`cacheScope` with `"public"` when your handler leaves it unset, and omits both fields entirely on an
128-
older connection. So you don't have to branch on protocol version yourself.
127+
`cacheScope` with `"private"` when your handler leaves it unset, and omits both fields entirely on an
128+
older connection. Set `cache_scope="public"` only when the result is the same for every user.
129+
You don't have to branch on protocol version yourself.
129130

130131
## What this SDK doesn't do
131132

‎docs_src/skills/tutorial001_client.py‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,8 @@
66

77

88
async def main() -> None:
9-
skills = Skills()
10-
async with Client("http://localhost:8000/mcp", extensions=[skills]) as client:
11-
catalog = skills.bind(client)
9+
async with Client("http://localhost:8000/mcp", extensions=[Skills()]) as client:
10+
catalog = client.extension(Skills)
1211
for skill in await catalog.list_skills():
1312
print(skill.uri, skill.frontmatter["description"])
1413

‎src/mcp/client/client.py‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@
4949
from mcp.client._probe import negotiate_auto
5050
from mcp.client._transport import Transport
5151
from mcp.client.caching import CacheConfig, CacheMode, ClientResponseCache, InMemoryResponseCacheStore
52-
from mcp.client.extension import ClaimContext, ClientExtension, NotificationBinding, ResultClaim
52+
from mcp.client.extension import ClaimContext, ClientExtension, ClientExtensionBinding, NotificationBinding, ResultClaim
5353
from mcp.client.session import (
5454
ClientRequestContext,
5555
ClientSession,
@@ -87,6 +87,7 @@
8787
_T = TypeVar("_T")
8888
_ResultT = TypeVar("_ResultT")
8989
_CacheableT = TypeVar("_CacheableT", bound=CacheableResult)
90+
_BoundT = TypeVar("_BoundT")
9091

9192
_Connector = Callable[[AsyncExitStack, ConnectMode, bool], Awaitable["Dispatcher[Any]"]]
9293
"""Resolved at ``__post_init__`` from the shape of ``server`` alone: enter whatever resources
@@ -376,6 +377,7 @@ async def main():
376377
_connect: _Connector = field(init=False, repr=False, compare=False)
377378
_response_cache: ClientResponseCache | None = field(init=False, default=None, repr=False, compare=False)
378379
_folded_extensions: _FoldedExtensions = field(init=False, repr=False, compare=False)
380+
_registered_extensions: tuple[ClientExtension, ...] = field(init=False, repr=False, compare=False)
379381

380382
def __post_init__(self) -> None:
381383
if self.mode not in ("legacy", "auto") and self.mode not in MODERN_PROTOCOL_VERSIONS:
@@ -389,6 +391,7 @@ def __post_init__(self) -> None:
389391
)
390392

391393
self._folded_extensions = _fold_extensions(self.extensions)
394+
self._registered_extensions = tuple(self.extensions or ())
392395

393396
srv = self.server
394397
if isinstance(srv, MCPServer):
@@ -493,6 +496,19 @@ def session(self) -> ClientSession:
493496
raise RuntimeError("Client must be used within an async context manager")
494497
return self._session
495498

499+
def extension(self, extension_type: type[ClientExtensionBinding[_BoundT]]) -> _BoundT:
500+
"""Return the typed API of a registered extension for this connection.
501+
502+
Raises:
503+
RuntimeError: If the client is not connected.
504+
ValueError: If no extension of this type was registered.
505+
"""
506+
session = self.session
507+
for extension in self._registered_extensions:
508+
if isinstance(extension, extension_type):
509+
return cast(ClientExtensionBinding[_BoundT], extension).bind(session)
510+
raise ValueError(f"client extension {extension_type.__name__!r} is not registered")
511+
496512
# TODO(maxisbey): the by-construction shape is for __aenter__ to return a connected-view
497513
# type whose protocol_version/server_capabilities are non-Optional fields,
498514
# eliminating these guards (and the one in .session). Same family as resolving the

‎src/mcp/client/extension.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
1616
from pydantic import AliasChoices, AliasPath, BaseModel
1717
from pydantic.fields import FieldInfo
18+
from typing_extensions import Protocol
1819

1920
from mcp.shared.extension import validate_extension_identifier
2021

@@ -24,6 +25,7 @@
2425
__all__ = [
2526
"ClaimContext",
2627
"ClientExtension",
28+
"ClientExtensionBinding",
2729
"NotificationBinding",
2830
"ResultClaim",
2931
"UnexpectedClaimedResult",
@@ -54,6 +56,15 @@ def _wire_keys(name: str, field: FieldInfo) -> frozenset[str]:
5456

5557
ClaimedT = TypeVar("ClaimedT", bound=Result)
5658
NotifyParamsT = TypeVar("NotifyParamsT", bound=BaseModel)
59+
BoundT_co = TypeVar("BoundT_co", covariant=True)
60+
61+
62+
class ClientExtensionBinding(Protocol[BoundT_co]):
63+
"""An extension that exposes a typed API for one connected client."""
64+
65+
def bind(self, session: ClientSession) -> BoundT_co:
66+
"""Return the extension API bound to `session`."""
67+
...
5768

5869

5970
@dataclass(frozen=True, kw_only=True)

‎src/mcp/client/skills.py‎

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@
22
33
`Skills` is an opt-in [`ClientExtension`](../advanced/extensions.md) for talking
44
to a skills catalog server. Register it with `Client(extensions=[Skills()])`,
5-
then call `bind(client)` for the SEP-2640 verbs — `list_skills`, `get_skill`,
5+
then call `client.extension(Skills)` for the SEP-2640 verbs — `list_skills`, `get_skill`,
66
`read_skill_uri`, and `read_directory` — tied to that connection:
77
8-
async with Client("http://localhost:8000/mcp", extensions=[skills := Skills()]) as client:
9-
for skill in await skills.bind(client).list_skills():
8+
async with Client("http://localhost:8000/mcp", extensions=[Skills()]) as client:
9+
for skill in await client.extension(Skills).list_skills():
1010
print(skill.uri, skill.frontmatter["description"])
1111
12-
`bind(client)` returns a `BoundSkills`. Its catalog verbs — `list_skills`,
12+
`client.extension(Skills)` returns a `BoundSkills`. Its catalog verbs — `list_skills`,
1313
`get_skill`, and `read_directory` — check that the server advertises the
1414
extension and validate its response; `list_skills` and `read_directory` follow
1515
`nextCursor` to completion, so one call returns every page's results.
@@ -19,8 +19,6 @@
1919

2020
from __future__ import annotations
2121

22-
from typing import TYPE_CHECKING
23-
2422
from mcp_types import ReadResourceResult, Resource
2523

2624
from mcp.client.extension import ClientExtension
@@ -41,35 +39,28 @@
4139
)
4240
from mcp.shared.skills import verify_skill_resource as verify_skill_resource
4341

44-
if TYPE_CHECKING:
45-
from mcp.client.client import Client
46-
4742
__all__ = ["BoundSkills", "Skills", "verify_skill_resource"]
4843

4944

5045
class Skills(ClientExtension):
51-
"""The client-side Skills extension: register, then `bind` for typed verbs.
46+
"""The client-side Skills extension: register, then access its typed verbs.
5247
5348
Pass an instance to `Client(extensions=[Skills()])` — this advertises
5449
`io.modelcontextprotocol/skills` under the client's capabilities — and call
55-
`bind(client)` once the client is connected for a `BoundSkills` handle.
50+
`client.extension(Skills)` once connected for a `BoundSkills` handle.
5651
"""
5752

5853
identifier = EXTENSION_ID
5954

60-
def bind(self, client: Client) -> BoundSkills:
61-
"""Return the SEP-2640 verbs bound to `client`'s connected session.
62-
63-
Raises:
64-
RuntimeError: If `client` has not entered its `async with` block yet.
65-
"""
66-
return BoundSkills(client.session)
55+
def bind(self, session: ClientSession) -> BoundSkills:
56+
"""Return the SEP-2640 verbs bound to `session`."""
57+
return BoundSkills(session)
6758

6859

6960
class BoundSkills:
7061
"""The SEP-2640 verbs bound to one connected session.
7162
72-
Obtain it from `Skills.bind(client)`. The catalog verbs — `list_skills`,
63+
Obtain it from `client.extension(Skills)`. The catalog verbs — `list_skills`,
7364
`get_skill`, and `read_directory` — check that the server advertises the
7465
extension and validate its response against the SEP-2640 conformance rules
7566
before returning; `list_skills` and `read_directory` also follow

‎src/mcp/server/skills.py‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -162,11 +162,9 @@ def _finalize_cacheable(result: CacheableResult, protocol_version: str) -> Handl
162162
`skills/list` and `skills/get` are extension methods, so — unlike a core spec
163163
method — the runner's per-version surface sieve never runs on their results;
164164
nothing else strips these fields for a legacy connection. `CacheableResult`
165-
defaults to `cache_scope="private"`; SEP-2640 calls for `"public"` when unset.
165+
defaults to `cache_scope="private"`, keeping user-specific entries out of shared caches.
166166
"""
167167
if protocol_version in MODERN_PROTOCOL_VERSIONS:
168-
if "cache_scope" not in result.model_fields_set:
169-
result = result.model_copy(update={"cache_scope": "public"})
170168
return result
171169
dumped = result.model_dump(by_alias=True, mode="json", exclude_none=True)
172170
dumped.pop("ttlMs", None)

‎src/mcp/shared/skills.py‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -275,16 +275,15 @@ def validate_directory_result(uri: str, result: ReadDirectoryResult) -> None:
275275
"""Validate that each entry in `result.resources` is a unique direct child of `uri`.
276276
277277
Checks containment and shape only — that every listed resource is a direct
278-
child of `uri` with a unique `uri` and `name`. It cannot confirm the listing
278+
child of `uri` with a unique `uri`. It cannot confirm the listing
279279
is exhaustive, since it has no independent view of the directory's contents.
280280
281281
Raises:
282282
ValueError: If `uri` is malformed, or any entry is not a direct child,
283-
or two entries share a `uri` or `name`.
283+
or two entries share a `uri`.
284284
"""
285285
scheme, netloc, parent_path = parse_directory_uri(uri)
286286
seen_uris: set[str] = set()
287-
seen_names: set[str] = set()
288287
prefix = parent_path.rstrip("/") + "/" if parent_path.rstrip("/") else "/"
289288
for resource in result.resources:
290289
child = urlsplit(resource.uri)
@@ -295,10 +294,9 @@ def validate_directory_result(uri: str, result: ReadDirectoryResult) -> None:
295294
relative = child.path.removeprefix(prefix)
296295
if relative == child.path or not relative or "/" in relative or relative in (".", ".."):
297296
raise ValueError(f"resource {resource.uri!r} is not a direct child of directory {uri!r}")
298-
if resource.uri in seen_uris or resource.name in seen_names:
297+
if resource.uri in seen_uris:
299298
raise ValueError(f"directory {uri!r} contains a duplicate child {resource.uri!r}")
300299
seen_uris.add(resource.uri)
301-
seen_names.add(resource.name)
302300

303301

304302
def verify_skill_resource(skill: Skill, uri: str, content: bytes) -> None:

‎tests/client/test_client_extensions.py‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
from mcp.client import ClaimContext, ClientExtension, NotificationBinding, ResultClaim, advertise
1919
from mcp.client.client import Client
20-
from mcp.client.session import ClientRequestContext, _CallToolResultAdapter
20+
from mcp.client.session import ClientRequestContext, ClientSession, _CallToolResultAdapter
2121
from mcp.server import Server, ServerRequestContext
2222
from mcp.server.context import CallNext, HandlerResult
2323
from mcp.server.extension import Extension
@@ -59,6 +59,13 @@ def claims(self) -> Sequence[ResultClaim[Any]]:
5959
return [ResultClaim(result_type="voucher", model=VoucherResult, resolve=self._resolve)]
6060

6161

62+
class _SessionExtension(ClientExtension):
63+
identifier = "com.example/session"
64+
65+
def bind(self, session: ClientSession) -> ClientSession:
66+
return session
67+
68+
6269
class _VoucherIssuer(Extension):
6370
"""Server half: rewrites every `tools/call` result into the vendor-claimed shape."""
6471

@@ -166,6 +173,30 @@ def test_mapping_extensions_get_the_migration_error() -> None:
166173
)
167174

168175

176+
async def test_extension_returns_a_custom_extensions_typed_binding() -> None:
177+
"""SDK-defined: custom extensions can expose typed methods through Client.extension."""
178+
async with Client(_add_server(), extensions=[advertise("com.example/other"), _SessionExtension()]) as client:
179+
bound = client.extension(_SessionExtension)
180+
assert_type(bound, ClientSession)
181+
assert bound is client.session
182+
183+
184+
async def test_extension_rejects_an_unregistered_type() -> None:
185+
"""SDK-defined: a client cannot bind an extension it did not register."""
186+
async with Client(_add_server()) as client:
187+
with pytest.raises(ValueError) as exc_info:
188+
client.extension(_SessionExtension)
189+
assert str(exc_info.value) == snapshot("client extension '_SessionExtension' is not registered")
190+
191+
192+
def test_extension_requires_a_connected_client() -> None:
193+
"""SDK-defined: extension methods require a live session."""
194+
client = Client(_add_server(), extensions=[_SessionExtension()])
195+
with pytest.raises(RuntimeError) as exc_info:
196+
client.extension(_SessionExtension)
197+
assert str(exc_info.value) == snapshot("Client must be used within an async context manager")
198+
199+
169200
def test_one_extension_claiming_a_tag_twice_reads_as_one_owner() -> None:
170201
"""SDK-defined: a self-conflict names the one extension once, not as a pair."""
171202
with pytest.raises(ValueError) as exc_info:

‎tests/client/test_skills.py‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ async def _skills(server: MCPServer) -> AsyncIterator[BoundSkills]:
142142
"""Register the client `Skills` extension, connect, and yield the bound verbs."""
143143
extension = ClientSkills()
144144
async with Client(server, extensions=[extension]) as client:
145-
yield extension.bind(client)
145+
yield client.extension(ClientSkills)
146146

147147

148148
async def test_list_skills_follows_next_cursor_to_completion() -> None:
@@ -222,6 +222,29 @@ async def test_read_directory_follows_next_cursor_to_completion() -> None:
222222
]
223223

224224

225+
async def test_read_directory_accepts_distinct_children_with_the_same_name() -> None:
226+
"""SEP-2640 Directory Listing: child identity comes from URI, not Resource.name."""
227+
228+
async def handler(ctx: ServerRequestContext[Any, Any], params: ReadDirectoryParams) -> ReadDirectoryResult:
229+
return ReadDirectoryResult(
230+
resources=[
231+
Resource(uri="skill://git-workflow/references/a.md", name="example"),
232+
Resource(uri="skill://git-workflow/references/b.md", name="example"),
233+
]
234+
)
235+
236+
server = MCPServer(
237+
"catalog",
238+
extensions=[Skills(list_skills=_paginated_list_handler(), get_skill=_get_skill, read_directory=handler)],
239+
)
240+
async with _skills(server) as skills:
241+
resources = await skills.read_directory("skill://git-workflow/references")
242+
assert [resource.uri for resource in resources] == [
243+
"skill://git-workflow/references/a.md",
244+
"skill://git-workflow/references/b.md",
245+
]
246+
247+
225248
async def test_read_directory_requires_the_directory_read_setting() -> None:
226249
"""The extension is advertised, but without `directoryRead: true`."""
227250
async with _skills(_server(with_directory_read=False)) as skills:

0 commit comments

Comments
 (0)