Skip to content

Commit 60e9c7a

Browse files
authored
Add in-memory transport (#25)
## Goal Support running an MCP server in the same process as the client, while preserving MCP abstractions. ## Details 1. **(core change)** Adds a new `memory` transport module that enables in-process client-server communication. This includes: - `create_client_server_memory_streams()` to create bidirectional memory streams - `create_connected_server_and_client_session()` to establish an in-process client-server connection 3. (minor) Enhances error handling and timeout support: - Adds configurable read timeouts to sessions via `read_timeout_seconds` parameter - Improves exception handling in the server with a new `raise_exceptions` flag to control whether errors are returned to clients or raised directly - Ensures proper cleanup of request context tokens in error cases 4. (minor) Makes server improvements: - Adds built-in ping handler support
1 parent 1a60e1b commit 60e9c7a

File tree

9 files changed

+210
-10
lines changed

9 files changed

+210
-10
lines changed

mcp_python/client/session.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from datetime import timedelta
2+
13
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
24
from pydantic import AnyUrl
35

@@ -36,8 +38,15 @@ def __init__(
3638
self,
3739
read_stream: MemoryObjectReceiveStream[JSONRPCMessage | Exception],
3840
write_stream: MemoryObjectSendStream[JSONRPCMessage],
41+
read_timeout_seconds: timedelta | None = None,
3942
) -> None:
40-
super().__init__(read_stream, write_stream, ServerRequest, ServerNotification)
43+
super().__init__(
44+
read_stream,
45+
write_stream,
46+
ServerRequest,
47+
ServerNotification,
48+
read_timeout_seconds=read_timeout_seconds,
49+
)
4150

4251
async def initialize(self) -> InitializeResult:
4352
from mcp_python.types import (

mcp_python/server/__init__.py

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
ClientNotification,
1919
ClientRequest,
2020
CompleteRequest,
21+
EmptyResult,
2122
ErrorData,
2223
JSONRPCMessage,
2324
ListPromptsRequest,
@@ -27,6 +28,7 @@
2728
ListToolsRequest,
2829
ListToolsResult,
2930
LoggingLevel,
31+
PingRequest,
3032
ProgressNotification,
3133
Prompt,
3234
PromptReference,
@@ -52,9 +54,11 @@
5254
class Server:
5355
def __init__(self, name: str):
5456
self.name = name
55-
self.request_handlers: dict[type, Callable[..., Awaitable[ServerResult]]] = {}
57+
self.request_handlers: dict[type, Callable[..., Awaitable[ServerResult]]] = {
58+
PingRequest: _ping_handler,
59+
}
5660
self.notification_handlers: dict[type, Callable[..., Awaitable[None]]] = {}
57-
logger.info(f"Initializing server '{name}'")
61+
logger.debug(f"Initializing server '{name}'")
5862

5963
def create_initialization_options(self) -> types.InitializationOptions:
6064
"""Create initialization options from this server instance."""
@@ -63,9 +67,13 @@ def pkg_version(package: str) -> str:
6367
try:
6468
from importlib.metadata import version
6569

66-
return version(package)
70+
v = version(package)
71+
if v is not None:
72+
return v
6773
except Exception:
68-
return "unknown"
74+
pass
75+
76+
return "unknown"
6977

7078
return types.InitializationOptions(
7179
server_name=self.name,
@@ -330,6 +338,11 @@ async def run(
330338
read_stream: MemoryObjectReceiveStream[JSONRPCMessage | Exception],
331339
write_stream: MemoryObjectSendStream[JSONRPCMessage],
332340
initialization_options: types.InitializationOptions,
341+
# When True, exceptions are returned as messages to the client.
342+
# When False, exceptions are raised, which will cause the server to shut down
343+
# but also make tracing exceptions much easier during testing and when using
344+
# in-process servers.
345+
raise_exceptions: bool = False,
333346
):
334347
with warnings.catch_warnings(record=True) as w:
335348
async with ServerSession(
@@ -349,6 +362,7 @@ async def run(
349362
f"Dispatching request of type {type(req).__name__}"
350363
)
351364

365+
token = None
352366
try:
353367
# Set our global state that can be retrieved via
354368
# app.get_request_context()
@@ -360,12 +374,16 @@ async def run(
360374
)
361375
)
362376
response = await handler(req)
363-
# Reset the global state after we are done
364-
request_ctx.reset(token)
365377
except Exception as err:
378+
if raise_exceptions:
379+
raise err
366380
response = ErrorData(
367381
code=0, message=str(err), data=None
368382
)
383+
finally:
384+
# Reset the global state after we are done
385+
if token is not None:
386+
request_ctx.reset(token)
369387

370388
await message.respond(response)
371389
else:
@@ -399,3 +417,7 @@ async def run(
399417
logger.info(
400418
f"Warning: {warning.category.__name__}: {warning.message}"
401419
)
420+
421+
422+
async def _ping_handler(request: PingRequest) -> ServerResult:
423+
return ServerResult(EmptyResult())

mcp_python/shared/memory.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""
2+
In-memory transports
3+
"""
4+
5+
from contextlib import asynccontextmanager
6+
from datetime import timedelta
7+
from typing import AsyncGenerator
8+
9+
import anyio
10+
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
11+
12+
from mcp_python.client.session import ClientSession
13+
from mcp_python.server import Server
14+
from mcp_python.types import JSONRPCMessage
15+
16+
MessageStream = tuple[
17+
MemoryObjectReceiveStream[JSONRPCMessage | Exception],
18+
MemoryObjectSendStream[JSONRPCMessage]
19+
]
20+
21+
@asynccontextmanager
22+
async def create_client_server_memory_streams() -> AsyncGenerator[
23+
tuple[MessageStream, MessageStream],
24+
None
25+
]:
26+
"""
27+
Creates a pair of bidirectional memory streams for client-server communication.
28+
29+
Returns:
30+
A tuple of (client_streams, server_streams) where each is a tuple of
31+
(read_stream, write_stream)
32+
"""
33+
# Create streams for both directions
34+
server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[
35+
JSONRPCMessage | Exception
36+
](1)
37+
client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[
38+
JSONRPCMessage | Exception
39+
](1)
40+
41+
client_streams = (server_to_client_receive, client_to_server_send)
42+
server_streams = (client_to_server_receive, server_to_client_send)
43+
44+
async with (
45+
server_to_client_receive,
46+
client_to_server_send,
47+
client_to_server_receive,
48+
server_to_client_send,
49+
):
50+
yield client_streams, server_streams
51+
52+
53+
@asynccontextmanager
54+
async def create_connected_server_and_client_session(
55+
server: Server,
56+
read_timeout_seconds: timedelta | None = None,
57+
raise_exceptions: bool = False,
58+
) -> AsyncGenerator[ClientSession, None]:
59+
"""Creates a ClientSession that is connected to a running MCP server."""
60+
async with create_client_server_memory_streams() as (
61+
client_streams,
62+
server_streams,
63+
):
64+
client_read, client_write = client_streams
65+
server_read, server_write = server_streams
66+
67+
# Create a cancel scope for the server task
68+
async with anyio.create_task_group() as tg:
69+
tg.start_soon(
70+
lambda: server.run(
71+
server_read,
72+
server_write,
73+
server.create_initialization_options(),
74+
raise_exceptions=raise_exceptions,
75+
)
76+
)
77+
78+
try:
79+
async with ClientSession(
80+
read_stream=client_read,
81+
write_stream=client_write,
82+
read_timeout_seconds=read_timeout_seconds,
83+
) as client_session:
84+
await client_session.initialize()
85+
yield client_session
86+
finally:
87+
tg.cancel_scope.cancel()

mcp_python/shared/session.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
from contextlib import AbstractAsyncContextManager
2+
from datetime import timedelta
23
from typing import Generic, TypeVar
34

45
import anyio
56
import anyio.lowlevel
7+
import httpx
68
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
79
from pydantic import BaseModel
810

@@ -87,13 +89,16 @@ def __init__(
8789
write_stream: MemoryObjectSendStream[JSONRPCMessage],
8890
receive_request_type: type[ReceiveRequestT],
8991
receive_notification_type: type[ReceiveNotificationT],
92+
# If none, reading will never time out
93+
read_timeout_seconds: timedelta | None = None,
9094
) -> None:
9195
self._read_stream = read_stream
9296
self._write_stream = write_stream
9397
self._response_streams = {}
9498
self._request_id = 0
9599
self._receive_request_type = receive_request_type
96100
self._receive_notification_type = receive_notification_type
101+
self._read_timeout_seconds = read_timeout_seconds
97102

98103
self._incoming_message_stream_writer, self._incoming_message_stream_reader = (
99104
anyio.create_memory_object_stream[
@@ -147,7 +152,25 @@ async def send_request(
147152

148153
await self._write_stream.send(JSONRPCMessage(jsonrpc_request))
149154

150-
response_or_error = await response_stream_reader.receive()
155+
try:
156+
with anyio.fail_after(
157+
None if self._read_timeout_seconds is None
158+
else self._read_timeout_seconds.total_seconds()
159+
):
160+
response_or_error = await response_stream_reader.receive()
161+
except TimeoutError:
162+
raise McpError(
163+
ErrorData(
164+
code=httpx.codes.REQUEST_TIMEOUT,
165+
message=(
166+
f"Timed out while waiting for response to "
167+
f"{request.__class__.__name__}. Waited "
168+
f"{self._read_timeout_seconds} seconds."
169+
),
170+
)
171+
172+
)
173+
151174
if isinstance(response_or_error, JSONRPCError):
152175
raise McpError(response_or_error.error)
153176
else:

mcp_python/types.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,16 +141,19 @@ class ErrorData(BaseModel):
141141

142142
code: int
143143
"""The error type that occurred."""
144+
144145
message: str
145146
"""
146147
A short description of the error. The message SHOULD be limited to a concise single
147148
sentence.
148149
"""
150+
149151
data: Any | None = None
150152
"""
151153
Additional information about the error. The value of this member is defined by the
152154
sender (e.g. detailed error information, nested errors etc.).
153155
"""
156+
154157
model_config = ConfigDict(extra="allow")
155158

156159

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "mcp-python"
7-
version = "0.4.0.dev"
7+
version = "0.5.0dev"
88
description = "Model Context Protocol implementation for Python"
99
readme = "README.md"
1010
requires-python = ">=3.10"

tests/conftest.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import pytest
2+
from pydantic import AnyUrl
3+
4+
from mcp_python.server import Server
5+
from mcp_python.server.types import InitializationOptions
6+
from mcp_python.types import Resource, ServerCapabilities
7+
8+
TEST_INITIALIZATION_OPTIONS = InitializationOptions(
9+
server_name="my_mcp_server",
10+
server_version="0.1.0",
11+
capabilities=ServerCapabilities(),
12+
)
13+
14+
@pytest.fixture
15+
def mcp_server() -> Server:
16+
server = Server(name="test_server")
17+
18+
@server.list_resources()
19+
async def handle_list_resources():
20+
return [
21+
Resource(
22+
uri=AnyUrl("memory://test"),
23+
name="Test Resource",
24+
description="A test resource"
25+
)
26+
]
27+
28+
return server

tests/shared/test_memory.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import pytest
2+
from typing_extensions import AsyncGenerator
3+
4+
from mcp_python.client.session import ClientSession
5+
from mcp_python.server import Server
6+
from mcp_python.shared.memory import (
7+
create_connected_server_and_client_session,
8+
)
9+
from mcp_python.types import (
10+
EmptyResult,
11+
)
12+
13+
14+
@pytest.fixture
15+
async def client_connected_to_server(
16+
mcp_server: Server,
17+
) -> AsyncGenerator[ClientSession, None]:
18+
async with create_connected_server_and_client_session(mcp_server) as client_session:
19+
yield client_session
20+
21+
22+
@pytest.mark.anyio
23+
async def test_memory_server_and_client_connection(
24+
client_connected_to_server: ClientSession,
25+
):
26+
"""Shows how a client and server can communicate over memory streams."""
27+
response = await client_connected_to_server.send_ping()
28+
assert isinstance(response, EmptyResult)

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)