Skip to content

Commit 7db42b6

Browse files
committed
Decouple standalone client typing from the full SDK
1 parent e49a483 commit 7db42b6

10 files changed

Lines changed: 209 additions & 181 deletions

File tree

‎.github/workflows/shared.yml‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,13 @@ jobs:
6868
uv run --isolated --no-project --find-links dist --with "$package" \
6969
python scripts/check_client_package.py
7070
done
71+
- name: Type-check the standalone client package
72+
run: |
73+
uv export --frozen --package mcp-client --no-default-groups --no-dev --no-emit-workspace \
74+
--output-file "$RUNNER_TEMP/client-requirements.txt"
75+
uv run --isolated --no-project --find-links dist --with dist/mcp_client-*.whl \
76+
--with-requirements "$RUNNER_TEMP/client-requirements.txt" --with pyright==1.1.405 \
77+
python scripts/check_client_types.py
7178
- name: Import the full SDK with the client package first
7279
run: |
7380
uv run --isolated --no-project --find-links dist --with dist/mcp-*.whl python -c \

‎pyproject.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ include = [
169169
"examples/snippets",
170170
"examples/clients",
171171
"scripts/check_client_package.py",
172+
"scripts/check_client_types.py",
172173
"scripts/docs/build_config.py",
173174
"scripts/docs/translations.py",
174175
]

‎scripts/check_client_types.py‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""Type-check the installed client package and reject an invalid constructor argument."""
2+
3+
import importlib.util
4+
import json
5+
import subprocess
6+
import sys
7+
import tempfile
8+
from pathlib import Path
9+
10+
import mcp_client
11+
12+
assert importlib.util.find_spec("mcp") is None
13+
14+
with tempfile.TemporaryDirectory() as directory:
15+
root = Path(directory)
16+
consumer = root / "consumer.py"
17+
consumer.write_text(
18+
"from mcp_client import Client, StdioServerParameters\n"
19+
'Client("https://example.com/mcp")\n'
20+
'Client(StdioServerParameters(command="python"))\n'
21+
"Client(42)\n",
22+
encoding="utf-8",
23+
)
24+
config = root / "pyrightconfig.json"
25+
config.write_text(
26+
json.dumps(
27+
{
28+
"pythonPath": sys.executable,
29+
"typeCheckingMode": "strict",
30+
}
31+
),
32+
encoding="utf-8",
33+
)
34+
result = subprocess.run(
35+
["pyright", "--project", str(config), "--outputjson", *mcp_client.__path__, str(consumer)],
36+
cwd=root,
37+
capture_output=True,
38+
text=True,
39+
encoding="utf-8",
40+
check=False,
41+
)
42+
diagnostics = json.loads(result.stdout)["generalDiagnostics"]
43+
errors = [diagnostic for diagnostic in diagnostics if diagnostic["severity"] == "error"]
44+
assert result.returncode == 1, result.stdout + result.stderr
45+
assert len(errors) == 1, result.stdout
46+
assert errors[0]["file"] == str(consumer), result.stdout
47+
assert errors[0]["range"]["start"]["line"] == 3, result.stdout
48+
assert errors[0]["rule"] == "reportArgumentType", result.stdout

‎src/mcp-client/mcp_client/client/_memory.py‎

Lines changed: 0 additions & 109 deletions
This file was deleted.

‎src/mcp-client/mcp_client/client/client.py‎

Lines changed: 8 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from collections.abc import Awaitable, Callable, Mapping, Sequence
99
from contextlib import AbstractAsyncContextManager, AsyncExitStack
1010
from dataclasses import KW_ONLY, dataclass, field
11-
from typing import TYPE_CHECKING, Any, Literal, TypeAlias, TypeVar, cast
11+
from typing import Any, Literal, TypeVar, cast
1212

1313
import anyio
1414
import anyio.lowlevel
@@ -40,7 +40,7 @@
4040
ServerCapabilities,
4141
)
4242
from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS, MODERN_PROTOCOL_VERSIONS
43-
from typing_extensions import deprecated
43+
from typing_extensions import Protocol, deprecated
4444

4545
from mcp_client.client._input_required import DEFAULT_INPUT_REQUIRED_MAX_ROUNDS, run_input_required_driver
4646
from mcp_client.client._probe import negotiate_auto
@@ -61,22 +61,12 @@
6161
from mcp_client.client.streamable_http import streamable_http_client
6262
from mcp_client.client.subscriptions import ServerEvent, Subscription
6363
from mcp_client.client.subscriptions import listen as _listen
64-
from mcp_client.shared.direct_dispatcher import create_direct_dispatcher_pair
6564
from mcp_client.shared.dispatcher import Dispatcher, ProgressFnT
6665
from mcp_client.shared.exceptions import MCPDeprecationWarning, MCPError
6766
from mcp_client.shared.extension import validate_extension_identifier
6867
from mcp_client.shared.jsonrpc_dispatcher import JSONRPCDispatcher
6968
from mcp_client.shared.subscriptions import event_to_notification
7069

71-
if TYPE_CHECKING:
72-
from mcp.server import Server
73-
from mcp.server.mcpserver import MCPServer
74-
75-
_InProcessServer: TypeAlias = Server[Any] | MCPServer
76-
else:
77-
# The full SDK binds its server types without making them a client dependency.
78-
_InProcessServer = Any
79-
8070
logger = logging.getLogger("mcp.client.client")
8171

8272
ConnectMode = Literal["legacy", "auto"] | str
@@ -105,28 +95,10 @@ async def connect(exit_stack: AsyncExitStack, _mode: ConnectMode, _raise_excepti
10595
return connect
10696

10797

108-
def _connect_inproc(server: Server[Any]) -> _Connector:
109-
"""Connector for an in-process ``Server``: legacy mode drives the stream loop via
110-
``InMemoryTransport``; any other mode drives the modern per-request path through a
111-
``DirectDispatcher`` peer pair (no streams, no JSON-RPC framing, no initialize handshake)."""
112-
113-
from mcp.server.runner import modern_on_request
114-
from mcp_client.client._memory import InMemoryTransport
115-
116-
async def connect(exit_stack: AsyncExitStack, mode: ConnectMode, raise_exceptions: bool) -> Dispatcher[Any]:
117-
if mode == "legacy":
118-
transport = InMemoryTransport(server, raise_exceptions=raise_exceptions)
119-
read_stream, write_stream = await exit_stack.enter_async_context(transport)
120-
return JSONRPCDispatcher(read_stream, write_stream)
121-
lifespan_state = await exit_stack.enter_async_context(server.lifespan(server))
122-
client_disp, server_disp = create_direct_dispatcher_pair(raise_handler_exceptions=raise_exceptions)
123-
tg = await exit_stack.enter_async_context(anyio.create_task_group())
124-
exit_stack.callback(server_disp.close)
125-
on_request = modern_on_request(server, lifespan_state)
126-
await tg.start(server_disp.run, on_request, _no_inbound_client_notifications)
127-
return client_disp
128-
129-
return connect
98+
class _InProcessServer(Protocol):
99+
async def __mcp_client_connect__(
100+
self, exit_stack: AsyncExitStack, mode: str, raise_exceptions: bool
101+
) -> Dispatcher[Any]: ...
130102

131103

132104
def _connected(value: _T | None) -> _T:
@@ -189,17 +161,6 @@ def _synthesize_discover(protocol_version: str) -> types.DiscoverResult:
189161
)
190162

191163

192-
async def _no_inbound_client_notifications(_dctx: Any, _method: str, _params: Mapping[str, Any] | None) -> None:
193-
"""Server-side inbound ``OnNotify`` for the modern in-process path — receives nothing.
194-
195-
At 2026-07-28 the spec defines no client→server notifications: ``initialized`` and
196-
``roots/list_changed`` are removed, and cancellation is structural (anyio scope cancel
197-
through the direct await, not a notify). Server→client notifications (progress, log
198-
messages) flow the other way via the per-request ``DispatchContext`` into the client's
199-
callbacks, and are not seen here.
200-
"""
201-
202-
203164
@dataclass(frozen=True)
204165
class _FoldedExtensions:
205166
"""`Client.extensions` instances folded into the shapes `ClientSession` consumes."""
@@ -278,7 +239,7 @@ class Client:
278239
```python
279240
import asyncio
280241
281-
from mcp import Client
242+
from mcp_client import Client
282243
283244
async def main():
284245
async with Client("http://localhost:8000/mcp") as client:
@@ -401,11 +362,7 @@ def __post_init__(self) -> None:
401362
elif isinstance(srv, AbstractAsyncContextManager):
402363
self._connect = _connect_transport(srv)
403364
else:
404-
from mcp.server.mcpserver import MCPServer
405-
406-
if isinstance(srv, MCPServer):
407-
srv = srv._lowlevel_server # pyright: ignore[reportPrivateUsage]
408-
self._connect = _connect_inproc(cast("Server[Any]", srv))
365+
self._connect = cast(_InProcessServer, srv).__mcp_client_connect__
409366

410367
if self.cache is not None:
411368
config = self.cache

‎src/mcp/client/__init__.py‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,16 +49,15 @@
4949
from mcp_client.client import (
5050
UnexpectedClaimedResult as UnexpectedClaimedResult,
5151
)
52-
from mcp_client.client import (
53-
_memory as _memory,
54-
)
5552
from mcp_client.client import (
5653
advertise as advertise,
5754
)
5855
from mcp_client.client import (
5956
stdio as stdio,
6057
)
6158

59+
from . import _memory as _memory
60+
6261
__all__ = [
6362
"CacheConfig",
6463
"CacheEntry",

0 commit comments

Comments
 (0)