Skip to content

Commit be1c7da

Browse files
author
Yassine Lassoued
committed
Merge branch 'ylassoued/feat-request' of github.com:ylassoued/python-sdk into ylassoued/feat-request
2 parents e4e2c1b + c31e7e1 commit be1c7da

File tree

6 files changed

+259
-7
lines changed

6 files changed

+259
-7
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,7 @@ You can mount the SSE server to an existing ASGI server using the `sse_app` meth
357357

358358
```python
359359
from starlette.applications import Starlette
360-
from starlette.routes import Mount, Host
360+
from starlette.routing import Mount, Host
361361
from mcp.server.fastmcp import FastMCP
362362

363363

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@
1212

1313
import mcp.types as types
1414

15+
from .win32 import (
16+
create_windows_process,
17+
get_windows_executable_command,
18+
terminate_windows_process,
19+
)
20+
1521
# Environment variables to inherit by default
1622
DEFAULT_INHERITED_ENV_VARS = (
1723
[
@@ -101,14 +107,18 @@ async def stdio_client(server: StdioServerParameters, errlog: TextIO = sys.stder
101107
read_stream_writer, read_stream = anyio.create_memory_object_stream(0)
102108
write_stream, write_stream_reader = anyio.create_memory_object_stream(0)
103109

104-
process = await anyio.open_process(
105-
[server.command, *server.args],
110+
command = _get_executable_command(server.command)
111+
112+
# Open process with stderr piped for capture
113+
process = await _create_platform_compatible_process(
114+
command=command,
115+
args=server.args,
106116
env=(
107117
{**get_default_environment(), **server.env}
108118
if server.env is not None
109119
else get_default_environment()
110120
),
111-
stderr=errlog,
121+
errlog=errlog,
112122
cwd=server.cwd,
113123
)
114124

@@ -159,4 +169,48 @@ async def stdin_writer():
159169
):
160170
tg.start_soon(stdout_reader)
161171
tg.start_soon(stdin_writer)
162-
yield read_stream, write_stream
172+
try:
173+
yield read_stream, write_stream
174+
finally:
175+
# Clean up process to prevent any dangling orphaned processes
176+
if sys.platform == "win32":
177+
await terminate_windows_process(process)
178+
else:
179+
process.terminate()
180+
181+
182+
def _get_executable_command(command: str) -> str:
183+
"""
184+
Get the correct executable command normalized for the current platform.
185+
186+
Args:
187+
command: Base command (e.g., 'uvx', 'npx')
188+
189+
Returns:
190+
str: Platform-appropriate command
191+
"""
192+
if sys.platform == "win32":
193+
return get_windows_executable_command(command)
194+
else:
195+
return command
196+
197+
198+
async def _create_platform_compatible_process(
199+
command: str,
200+
args: list[str],
201+
env: dict[str, str] | None = None,
202+
errlog: TextIO = sys.stderr,
203+
cwd: Path | str | None = None,
204+
):
205+
"""
206+
Creates a subprocess in a platform-compatible way.
207+
Returns a process handle.
208+
"""
209+
if sys.platform == "win32":
210+
process = await create_windows_process(command, args, env, errlog, cwd)
211+
else:
212+
process = await anyio.open_process(
213+
[command, *args], env=env, stderr=errlog, cwd=cwd
214+
)
215+
216+
return process

src/mcp/client/stdio/win32.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
"""
2+
Windows-specific functionality for stdio client operations.
3+
"""
4+
5+
import shutil
6+
import subprocess
7+
import sys
8+
from pathlib import Path
9+
from typing import TextIO
10+
11+
import anyio
12+
from anyio.abc import Process
13+
14+
15+
def get_windows_executable_command(command: str) -> str:
16+
"""
17+
Get the correct executable command normalized for Windows.
18+
19+
On Windows, commands might exist with specific extensions (.exe, .cmd, etc.)
20+
that need to be located for proper execution.
21+
22+
Args:
23+
command: Base command (e.g., 'uvx', 'npx')
24+
25+
Returns:
26+
str: Windows-appropriate command path
27+
"""
28+
try:
29+
# First check if command exists in PATH as-is
30+
if command_path := shutil.which(command):
31+
return command_path
32+
33+
# Check for Windows-specific extensions
34+
for ext in [".cmd", ".bat", ".exe", ".ps1"]:
35+
ext_version = f"{command}{ext}"
36+
if ext_path := shutil.which(ext_version):
37+
return ext_path
38+
39+
# For regular commands or if we couldn't find special versions
40+
return command
41+
except OSError:
42+
# Handle file system errors during path resolution
43+
# (permissions, broken symlinks, etc.)
44+
return command
45+
46+
47+
async def create_windows_process(
48+
command: str,
49+
args: list[str],
50+
env: dict[str, str] | None = None,
51+
errlog: TextIO = sys.stderr,
52+
cwd: Path | str | None = None,
53+
):
54+
"""
55+
Creates a subprocess in a Windows-compatible way.
56+
57+
Windows processes need special handling for console windows and
58+
process creation flags.
59+
60+
Args:
61+
command: The command to execute
62+
args: Command line arguments
63+
env: Environment variables
64+
errlog: Where to send stderr output
65+
cwd: Working directory for the process
66+
67+
Returns:
68+
A process handle
69+
"""
70+
try:
71+
# Try with Windows-specific flags to hide console window
72+
process = await anyio.open_process(
73+
[command, *args],
74+
env=env,
75+
# Ensure we don't create console windows for each process
76+
creationflags=subprocess.CREATE_NO_WINDOW # type: ignore
77+
if hasattr(subprocess, "CREATE_NO_WINDOW")
78+
else 0,
79+
stderr=errlog,
80+
cwd=cwd,
81+
)
82+
return process
83+
except Exception:
84+
# Don't raise, let's try to create the process without creation flags
85+
process = await anyio.open_process(
86+
[command, *args], env=env, stderr=errlog, cwd=cwd
87+
)
88+
return process
89+
90+
91+
async def terminate_windows_process(process: Process):
92+
"""
93+
Terminate a Windows process.
94+
95+
Note: On Windows, terminating a process with process.terminate() doesn't
96+
always guarantee immediate process termination.
97+
So we give it 2s to exit, or we call process.kill()
98+
which sends a SIGKILL equivalent signal.
99+
100+
Args:
101+
process: The process to terminate
102+
"""
103+
try:
104+
process.terminate()
105+
with anyio.fail_after(2.0):
106+
await process.wait()
107+
except TimeoutError:
108+
# Force kill if it doesn't terminate
109+
process.kill()

src/mcp/server/lowlevel/server.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,7 @@ def create_content(data: str | bytes, mime_type: str | None):
304304

305305
return types.BlobResourceContents(
306306
uri=req.params.uri,
307-
blob=base64.urlsafe_b64encode(data).decode(),
307+
blob=base64.b64encode(data).decode(),
308308
mimeType=mime_type or "application/octet-stream",
309309
)
310310

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""Test for base64 encoding issue in MCP server.
2+
3+
This test demonstrates the issue in server.py where the server uses
4+
urlsafe_b64encode but the BlobResourceContents validator expects standard
5+
base64 encoding.
6+
7+
The test should FAIL before fixing server.py to use b64encode instead of
8+
urlsafe_b64encode.
9+
After the fix, the test should PASS.
10+
"""
11+
12+
import base64
13+
from typing import cast
14+
15+
import pytest
16+
from pydantic import AnyUrl
17+
18+
from mcp.server.lowlevel.helper_types import ReadResourceContents
19+
from mcp.server.lowlevel.server import Server
20+
from mcp.types import (
21+
BlobResourceContents,
22+
ReadResourceRequest,
23+
ReadResourceRequestParams,
24+
ReadResourceResult,
25+
ServerResult,
26+
)
27+
28+
29+
@pytest.mark.anyio
30+
async def test_server_base64_encoding_issue():
31+
"""Tests that server response can be validated by BlobResourceContents.
32+
33+
This test will:
34+
1. Set up a server that returns binary data
35+
2. Extract the base64-encoded blob from the server's response
36+
3. Verify the encoded data can be properly validated by BlobResourceContents
37+
38+
BEFORE FIX: The test will fail because server uses urlsafe_b64encode
39+
AFTER FIX: The test will pass because server uses standard b64encode
40+
"""
41+
server = Server("test")
42+
43+
# Create binary data that will definitely result in + and / characters
44+
# when encoded with standard base64
45+
binary_data = bytes([x for x in range(255)] * 4)
46+
47+
# Register a resource handler that returns our test data
48+
@server.read_resource()
49+
async def read_resource(uri: AnyUrl) -> list[ReadResourceContents]:
50+
return [
51+
ReadResourceContents(
52+
content=binary_data, mime_type="application/octet-stream"
53+
)
54+
]
55+
56+
# Get the handler directly from the server
57+
handler = server.request_handlers[ReadResourceRequest]
58+
59+
# Create a request
60+
request = ReadResourceRequest(
61+
method="resources/read",
62+
params=ReadResourceRequestParams(uri=AnyUrl("test://resource")),
63+
)
64+
65+
# Call the handler to get the response
66+
result: ServerResult = await handler(request)
67+
68+
# After (fixed code):
69+
read_result: ReadResourceResult = cast(ReadResourceResult, result.root)
70+
blob_content = read_result.contents[0]
71+
72+
# First verify our test data actually produces different encodings
73+
urlsafe_b64 = base64.urlsafe_b64encode(binary_data).decode()
74+
standard_b64 = base64.b64encode(binary_data).decode()
75+
assert urlsafe_b64 != standard_b64, "Test data doesn't demonstrate"
76+
" encoding difference"
77+
78+
# Now validate the server's output with BlobResourceContents.model_validate
79+
# Before the fix: This should fail with "Invalid base64" because server
80+
# uses urlsafe_b64encode
81+
# After the fix: This should pass because server will use standard b64encode
82+
model_dict = blob_content.model_dump()
83+
84+
# Direct validation - this will fail before fix, pass after fix
85+
blob_model = BlobResourceContents.model_validate(model_dict)
86+
87+
# Verify we can decode the data back correctly
88+
decoded = base64.b64decode(blob_model.blob)
89+
assert decoded == binary_data

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)