Skip to content

Commit 5501f59

Browse files
authored
Merge branch 'main' into wip-nix
2 parents 6eb98e5 + d3dabd2 commit 5501f59

File tree

9 files changed

+177
-123
lines changed

9 files changed

+177
-123
lines changed

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,17 @@
1+
## [1.13.3](https://github.com/pathintegral-institute/mcpm.sh/compare/v1.13.2...v1.13.3) (2025-06-05)
2+
3+
4+
### Bug Fixes
5+
6+
* remove stdout stream handling for share ([#167](https://github.com/pathintegral-institute/mcpm.sh/issues/167)) ([11fddcc](https://github.com/pathintegral-institute/mcpm.sh/commit/11fddcc1ce68e96105cd33f1050f3dd0814ce9e0))
7+
8+
## [1.13.2](https://github.com/pathintegral-institute/mcpm.sh/compare/v1.13.1...v1.13.2) (2025-06-05)
9+
10+
11+
### Bug Fixes
12+
13+
* trigger semantic release for Windows compatibility fix ([16b5e44](https://github.com/pathintegral-institute/mcpm.sh/commit/16b5e44f01c34422c8f8c72528b781cd5f908e44))
14+
115
## [1.13.1](https://github.com/pathintegral-institute/mcpm.sh/compare/v1.13.0...v1.13.1) (2025-05-29)
216

317

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,31 @@ mcpm router share # Share the router to public
146146
mcpm router unshare # Unshare the router
147147
```
148148

149+
### 🤝 Share Management (`share`)
150+
151+
The `mcpm share` command allows you to take any shell command that starts an MCP server and instantly expose it as an SSE (Server-Sent Events) server. It uses `mcp-proxy` to handle the server transformation and then creates a secure tunnel for remote access, making your local MCP server accessible from anywhere.
152+
153+
This is particularly useful for quickly sharing a development server, a custom MCP server, or even a standard server with specific configurations without needing to deploy it publicly.
154+
155+
```bash
156+
# 🚀 Share a local MCP server
157+
mcpm share "COMMAND" # Replace COMMAND with your actual server start command
158+
159+
# ⚙️ Options
160+
# COMMAND: The shell command that starts your MCP server (e.g., "uvx mcp-server-fetch", "npx mcp-server"). This must be enclosed in quotes if it contains spaces.
161+
# --port PORT: Specify a local port for the mcp-proxy to listen on. Defaults to a random available port.
162+
# --address ADDRESS: Specify a public address for the tunnel (e.g., yourdomain.com:7000). If not provided, a random tunnel URL will be generated.
163+
# --http: If set, the tunnel will use HTTP instead of HTTPS. Use with caution.
164+
# --timeout TIMEOUT: Timeout in seconds for the mcp-proxy to wait for the server to start. Defaults to 60.
165+
# --retry RETRY: Number of times to retry starting the server if it fails. Defaults to 0.
166+
167+
# 💡 Usage Examples
168+
mcpm share "uvx mcp-server-fetch"
169+
mcpm share "npx mcp-server" --port 5000
170+
mcpm share "uv run my-mcp-server" --address myserver.com:7000
171+
mcpm share "npx -y @modelcontextprotocol/server-everything" --retry 3
172+
```
173+
149174
### 🛠️ Utilities (`util`)
150175

151176
```bash

README.zh-CN.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,31 @@ mcpm router share # 将router分享到公网
179179
mcpm router unshare # 取消分享
180180
```
181181

182+
### 🤝 共享管理 (`share`)
183+
184+
`mcpm share` 命令允许您将任何启动 MCP 服务器的 shell 命令,并立即将其公开为 SSE (Server-Sent Events) 服务器。它使用 `mcp-proxy` 处理服务器转换,然后创建一个安全隧道进行远程访问,使您的本地 MCP 服务器可以从任何地方访问。
185+
186+
这对于快速共享开发服务器、自定义 MCP 服务器,甚至具有特定配置的标准服务器(无需公开部署)特别有用。
187+
188+
```bash
189+
# 🚀 共享本地 MCP 服务器
190+
mcpm share "COMMAND" # 将 COMMAND 替换为您的实际服务器启动命令
191+
192+
# ⚙️ 选项
193+
# COMMAND: 启动 MCP 服务器的 shell 命令 (例如 "uvx mcp-server-fetch", "npx mcp-server")。如果包含空格,则必须用引号括起来。
194+
# --port PORT: 指定 mcp-proxy 监听的本地端口。默认为随机可用端口。
195+
# --address ADDRESS: 指定隧道的公共地址 (例如 yourdomain.com:7000)。如果未提供,将生成随机隧道 URL。
196+
# --http: 如果设置,隧道将使用 HTTP 而不是 HTTPS。请谨慎使用。
197+
# --timeout TIMEOUT: mcp-proxy 等待服务器启动的超时时间(秒)。默认为 60。
198+
# --retry RETRY: 如果服务器启动失败,重试启动服务器的次数。默认为 0。
199+
200+
# 💡 使用示例
201+
mcpm share "uvx mcp-server-fetch"
202+
mcpm share "npx mcp-server" --port 5000
203+
mcpm share "uv run my-mcp-server" --address myserver.com:7000
204+
mcpm share "npx -y @modelcontextprotocol/server-everything" --retry 3
205+
```
206+
182207
### 🛠️ 实用工具 (`util`)
183208

184209
```bash

pages/index.html

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1001,6 +1001,27 @@ <h3><span><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox
10011001
</div>
10021002
</div>
10031003

1004+
<h2><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-share-2"><circle cx="18" cy="5" r="3"></circle><circle cx="6" cy="12" r="3"></circle><circle cx="18" cy="19" r="3"></circle><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"></line><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"></line></svg> Share MCP Servers (<code>mcpm share</code>)</h2>
1005+
<p>Share a local MCP server with a public URL. MCPM uses mcp-proxy to expose a stdio MCP server as an SSE server and then creates a tunnel to make it accessible remotely.</p>
1006+
<div class="features">
1007+
<div class="feature">
1008+
<h3><span><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-terminal"><polyline points="4 17 10 17 10 7"></polyline><line x1="20" y1="17" x2="4" y2="17"></line></svg></span> Share a Server</h3>
1009+
<p>Share a server using a specific command:</p>
1010+
<div class="code-block">
1011+
<code><span class="command-prompt">$</span> mcpm share "uvx mcp-server-fetch"</code>
1012+
<button class="copy-button" data-command='mcpm share "uvx mcp-server-fetch"'><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg></button>
1013+
</div>
1014+
</div>
1015+
<div class="feature">
1016+
<h3><span><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-zap"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg></span> Share with Options</h3>
1017+
<p>Share a server and specify a port:</p>
1018+
<div class="code-block">
1019+
<code><span class="command-prompt">$</span> mcpm share "npx mcp-server" --port 5000</code>
1020+
<button class="copy-button" data-command='mcpm share "npx mcp-server" --port 5000'><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg></button>
1021+
</div>
1022+
</div>
1023+
</div>
1024+
10041025
<h2>Client Management</h2>
10051026
<div class="features">
10061027
<div class="feature">

src/mcpm/commands/share.py

Lines changed: 45 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,7 @@
22
Share command for MCPM - Share a single MCP server through a tunnel
33
"""
44

5-
import os
65
import secrets
7-
import select
86
import shlex
97
import shutil
108
import signal
@@ -27,15 +25,6 @@ def find_mcp_proxy() -> Optional[str]:
2725
return shutil.which("mcp-proxy")
2826

2927

30-
def make_non_blocking(file_obj):
31-
"""Make a file object non-blocking."""
32-
import fcntl
33-
34-
fd = file_obj.fileno()
35-
fl = fcntl.fcntl(fd, fcntl.F_GETFL)
36-
fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK)
37-
38-
3928
def wait_for_random_port(process: subprocess.Popen, timeout: int = 20) -> Optional[int]:
4029
"""
4130
Wait for mcp-proxy to output the random port information.
@@ -70,39 +59,26 @@ def wait_for_random_port(process: subprocess.Popen, timeout: int = 20) -> Option
7059
console.print(f"[red]Error output:[/]\n{stderr_output}")
7160
sys.exit(1)
7261

73-
# Use select to wait for data to be available without blocking
74-
readable = []
75-
if process.stdout:
76-
readable.append(process.stdout)
77-
if process.stderr:
78-
readable.append(process.stderr)
79-
80-
if readable:
81-
# Wait for up to 1 second for output
82-
r, _, _ = select.select(readable, [], [], 1.0)
83-
84-
# Process available output
85-
for stream in r:
86-
try:
87-
line = stream.readline()
88-
if line:
89-
print(line.rstrip())
90-
91-
# Check for port information
92-
if "Uvicorn running on http://" in line:
93-
try:
94-
url_part = line.split("Uvicorn running on ")[1].split(" ")[0]
95-
actual_port = int(url_part.split(":")[-1].strip())
96-
port_found = True
97-
console.print(
98-
f"[cyan]mcp-proxy SSE server running on port [bold]{actual_port}[/bold][/]"
99-
)
100-
break
101-
except (ValueError, IndexError):
102-
pass
103-
except (IOError, OSError):
104-
# Resource temporarily unavailable - this is normal for non-blocking IO
105-
pass
62+
# Process available output
63+
try:
64+
if process.stderr:
65+
line = process.stderr.readline()
66+
if line:
67+
console.print(line.rstrip())
68+
69+
# Check for port information
70+
if "Uvicorn running on http://" in line:
71+
try:
72+
url_part = line.split("Uvicorn running on ")[1].split(" ")[0]
73+
actual_port = int(url_part.split(":")[-1].strip())
74+
port_found = True
75+
console.print(f"[cyan]mcp-proxy SSE server running on port [bold]{actual_port}[/bold][/]")
76+
break
77+
except (ValueError, IndexError):
78+
pass
79+
except (IOError, OSError):
80+
# Resource temporarily unavailable - this is normal for non-blocking IO
81+
pass
10682
else:
10783
# No streams to read from, just wait a bit
10884
time.sleep(0.5)
@@ -144,12 +120,6 @@ def start_mcp_proxy(command: str, port: Optional[int] = None) -> Tuple[subproces
144120
console.print(f"[cyan]Running command: [bold]{' '.join(cmd_parts)}[/bold][/]")
145121
process = subprocess.Popen(cmd_parts, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1)
146122

147-
# Make stdout and stderr non-blocking
148-
if process.stdout:
149-
make_non_blocking(process.stdout)
150-
if process.stderr:
151-
make_non_blocking(process.stderr)
152-
153123
# If port is None, we need to parse the output to find the random port
154124
actual_port = port
155125
if not actual_port:
@@ -348,45 +318,31 @@ def signal_handler(sig, frame):
348318
if tunnel:
349319
tunnel.kill()
350320
break
351-
352-
# Use select to check for available output without blocking
353-
readable = []
354-
if server_process.stdout:
355-
readable.append(server_process.stdout)
356-
if server_process.stderr:
357-
readable.append(server_process.stderr)
358-
359-
if readable:
360-
# Wait for up to 1 second for output
361-
r, _, _ = select.select(readable, [], [], 1.0)
362-
363-
# Process available output
364-
for stream in r:
365-
try:
366-
line = stream.readline()
367-
if line:
368-
line_str = line.rstrip()
369-
print(line_str)
370-
last_activity_time = time.time()
371-
372-
# Check for error messages
373-
error_msg = monitor_for_errors(line_str)
374-
if error_msg and error_msg not in error_messages:
375-
console.print(f"[bold red]Error:[/] {error_msg}")
376-
error_messages.append(error_msg)
377-
server_error_detected = True
378-
379-
# If this is a critical error and we have retries left, restart
380-
if "Protocol initialization error" in error_msg and retries_left > 0:
381-
console.print(
382-
f"[yellow]Will attempt to restart ({retries_left} retries left)[/]"
383-
)
384-
# Break out of the loop to trigger a restart
385-
server_process.terminate()
386-
break
387-
except (IOError, OSError):
388-
# Resource temporarily unavailable - this is normal for non-blocking IO
389-
pass
321+
# Process available output
322+
try:
323+
if server_process.stderr:
324+
line = server_process.stderr.readline()
325+
if line:
326+
line_str = line.rstrip()
327+
console.print(line_str)
328+
last_activity_time = time.time()
329+
330+
# Check for error messages
331+
error_msg = monitor_for_errors(line_str)
332+
if error_msg and error_msg not in error_messages:
333+
console.print(f"[bold red]Error:[/] {error_msg}")
334+
error_messages.append(error_msg)
335+
server_error_detected = True
336+
337+
# If this is a critical error and we have retries left, restart
338+
if "Protocol initialization error" in error_msg and retries_left > 0:
339+
console.print(f"[yellow]Will attempt to restart ({retries_left} retries left)[/]")
340+
# Break out of the loop to trigger a restart
341+
server_process.terminate()
342+
break
343+
except (IOError, OSError):
344+
# Resource temporarily unavailable - this is normal for non-blocking IO
345+
pass
390346
else:
391347
# No streams to read from, just wait a bit
392348
time.sleep(0.5)

src/mcpm/router/router.py

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from collections import defaultdict
88
from contextlib import asynccontextmanager
99
from typing import Literal, Optional, Sequence, TextIO
10+
import asyncio
1011

1112
import uvicorn
1213
from deprecated import deprecated
@@ -46,6 +47,21 @@
4647
logger = logging.getLogger(__name__)
4748

4849

50+
class NoOpsResponse(Response):
51+
def __init__(self):
52+
super().__init__(content=b"", status_code=204)
53+
54+
async def __call__(self, scope, receive, send):
55+
await send(
56+
{
57+
"type": "http.response.start",
58+
"status": self.status_code,
59+
"headers": self.render_headers(),
60+
}
61+
)
62+
await send({"type": "http.response.body", "body": b"", "more_body": False})
63+
64+
4965
class MCPRouter:
5066
"""
5167
A router that aggregates multiple MCP servers (SSE/STDIO) and
@@ -615,17 +631,29 @@ async def get_sse_server_app(
615631
sse = RouterSseTransport("/messages/", api_key=api_key)
616632

617633
async def handle_sse(request: Request) -> Response:
618-
async with sse.connect_sse(
619-
request.scope,
620-
request.receive,
621-
request._send, # noqa: SLF001
622-
) as (read_stream, write_stream):
623-
await self.aggregated_server.run(
624-
read_stream,
625-
write_stream,
626-
self.aggregated_server.initialization_options,
627-
)
628-
return Response()
634+
try:
635+
async with sse.connect_sse(
636+
request.scope,
637+
request.receive,
638+
request._send, # noqa: SLF001
639+
) as (read_stream, write_stream):
640+
await self.aggregated_server.run(
641+
read_stream,
642+
write_stream,
643+
self.aggregated_server.initialization_options,
644+
)
645+
# Keep alive while client connected.
646+
# EventSourceResponse (inside connect_sse) manages the stream,
647+
# but this loop ensures this handler itself stays alive until disconnect.
648+
while not await request.is_disconnected():
649+
await asyncio.sleep(0.1)
650+
651+
except asyncio.CancelledError:
652+
raise
653+
except Exception as e:
654+
logger.error(f"Unexpected error in handle_sse (router.py): {e}", exc_info=True)
655+
finally:
656+
return NoOpsResponse()
629657

630658
lifespan_handler: t.Optional[Lifespan[Starlette]] = None
631659
if include_lifespan:

src/mcpm/router/transport.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -225,11 +225,11 @@ async def handle_post_message(self, scope: Scope, receive: Receive, send: Send):
225225
logger.warning(f"Failed to send error due to pipe issue: {pipe_err}")
226226
return
227227

228-
logger.debug(f"Sending message to writer: {message}")
229-
response = Response("Accepted", status_code=202)
230-
await response(scope, receive, send)
228+
# Send the 202 Accepted response
229+
accepted_response = Response("Accepted", status_code=202)
230+
await accepted_response(scope, receive, send)
231231

232-
# add error handling, catch possible pipe errors
232+
# Attempt to send the message to the writer
233233
try:
234234
await writer.send(SessionMessage(message=message))
235235
except (BrokenPipeError, ConnectionError, OSError) as e:
@@ -241,6 +241,9 @@ async def handle_post_message(self, scope: Scope, receive: Receive, send: Send):
241241
self._read_stream_writers.pop(session_id, None)
242242
self._session_id_to_identifier.pop(session_id, None)
243243

244+
# Implicitly return None. The original 'return response' is removed.
245+
return
246+
244247
def _validate_api_key(self, scope: Scope, api_key: str | None) -> bool:
245248
# If api_key is explicitly set to None, disable API key validation
246249
if self.api_key is None:

src/mcpm/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "1.13.1"
1+
__version__ = "1.13.3"

0 commit comments

Comments
 (0)