Skip to content

Commit cf3b71b

Browse files
committed
feat: add MCP proxy pattern convenience function
Implements the MCP proxy pattern to simplify bidirectional message forwarding between two transports (resolves #12). Key features: - Bidirectional message forwarding between client and server streams - Optional message inspection/transformation via callbacks - Error handling with continuation (errors don't stop the proxy) - Automatic stream cleanup on context exit - Python async/await pattern using anyio and memory streams Added: - src/mcp/shared/proxy.py: Core mcp_proxy() function - tests/shared/test_proxy.py: Comprehensive test suite (12 tests) - examples/servers/simple-proxy/: Working proxy server example - Export mcp_proxy in src/mcp/__init__.py The implementation adapts the TypeScript example to Python's stream-based transport model, replacing event callbacks with async context managers and memory streams.
1 parent 5983a65 commit cf3b71b

File tree

8 files changed

+767
-0
lines changed

8 files changed

+767
-0
lines changed
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
# Simple MCP Proxy Server
2+
3+
A simple MCP proxy server that demonstrates the `mcp_proxy()` pattern by forwarding messages bidirectionally between an MCP client and an MCP server.
4+
5+
## Features
6+
7+
- **Transparent proxying**: Forwards all MCP messages between client and server
8+
- **Message inspection**: Optional logging of messages flowing through the proxy
9+
- **Error handling**: Robust error handling with logging
10+
- **Easy configuration**: Simple command-line interface
11+
12+
## Use Cases
13+
14+
This proxy pattern is useful for:
15+
- **Debugging**: Inspect MCP protocol messages between client and server
16+
- **Monitoring**: Log and track MCP interactions
17+
- **Gateway**: Add authentication, rate limiting, or other middleware
18+
- **Testing**: Simulate network conditions or inject test messages
19+
20+
## Usage
21+
22+
Start the proxy server by pointing it to a backend MCP server:
23+
24+
```bash
25+
# Proxy to the simple-tool server with message inspection
26+
uv run mcp-simple-proxy --server-command uv --server-args run --server-args mcp-simple-tool
27+
28+
# Proxy without message inspection
29+
uv run mcp-simple-proxy --server-command uv --server-args run --server-args mcp-simple-tool --no-inspect
30+
31+
# Proxy to any other MCP server
32+
uv run mcp-simple-proxy --server-command python --server-args my_server.py
33+
```
34+
35+
The proxy listens on stdin/stdout (like a normal MCP server), so you can connect to it with any MCP client.
36+
37+
## Example Client Usage
38+
39+
```python
40+
import asyncio
41+
from mcp.client.session import ClientSession
42+
from mcp.client.stdio import StdioServerParameters, stdio_client
43+
44+
45+
async def main():
46+
# Connect to the proxy (which forwards to the backend server)
47+
async with stdio_client(
48+
StdioServerParameters(
49+
command="uv",
50+
args=["run", "mcp-simple-proxy",
51+
"--server-command", "uv",
52+
"--server-args", "run",
53+
"--server-args", "mcp-simple-tool"]
54+
)
55+
) as (read, write):
56+
async with ClientSession(read, write) as session:
57+
await session.initialize()
58+
59+
# List available tools (proxied from backend server)
60+
tools = await session.list_tools()
61+
print(f"Available tools: {tools}")
62+
63+
# Call a tool (proxied to backend server)
64+
result = await session.call_tool("fetch", {"url": "https://example.com"})
65+
print(f"Result: {result}")
66+
67+
68+
asyncio.run(main())
69+
```
70+
71+
## Message Inspection Output
72+
73+
When inspection is enabled, the proxy logs messages flowing through it:
74+
75+
```
76+
INFO:__main__:Starting MCP proxy to: uv run mcp-simple-tool
77+
INFO:__main__:Proxy connections established
78+
INFO:__main__:Proxy is running. Press Ctrl+C to stop.
79+
INFO:__main__:Client → Server: initialize (id: 1)
80+
INFO:__main__:Server → Client: Response (id: 1)
81+
INFO:__main__:Client → Server: initialized (id: N/A)
82+
INFO:__main__:Client → Server: tools/list (id: 2)
83+
INFO:__main__:Server → Client: Response (id: 2)
84+
INFO:__main__:Client → Server: tools/call (id: 3)
85+
INFO:__main__:Server → Client: Response (id: 3)
86+
```
87+
88+
## Advanced Usage
89+
90+
You can extend this example to add custom functionality:
91+
92+
```python
93+
from mcp.shared.proxy import mcp_proxy
94+
95+
async def transform_message(msg: SessionMessage) -> SessionMessage | None:
96+
# Add authentication headers
97+
# Rate limit requests
98+
# Cache responses
99+
# Drop certain messages
100+
return msg
101+
102+
async with mcp_proxy(
103+
client_streams=client_streams,
104+
server_streams=server_streams,
105+
on_client_message=transform_message,
106+
):
107+
await anyio.sleep_forever()
108+
```
109+
110+
## Architecture
111+
112+
```
113+
┌─────────┐ ┌───────────────┐ ┌─────────┐
114+
│ MCP │◄───────►│ MCP Proxy │◄───────►│ MCP │
115+
│ Client │ stdio │ (this) │ stdio │ Server │
116+
└─────────┘ └───────────────┘ └─────────┘
117+
118+
Message inspection,
119+
transformation, etc.
120+
```
121+
122+
The proxy creates two transport connections:
123+
- **Client-facing**: Communicates with the MCP client via stdin/stdout
124+
- **Server-facing**: Communicates with the backend MCP server via stdio_client
125+
126+
All messages are forwarded bidirectionally through the `mcp_proxy()` function.
127+
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Simple MCP Proxy Server."""
2+
3+
from .server import main
4+
5+
__all__ = ["main"]
6+
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
"""Main entry point for mcp-simple-proxy."""
2+
3+
import sys
4+
5+
from .server import main
6+
7+
if __name__ == "__main__":
8+
sys.exit(main())
9+
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"""
2+
Simple MCP Proxy Server Example
3+
4+
This example demonstrates how to use the mcp_proxy() function to create a proxy
5+
that forwards messages between a client and a server, with optional message inspection.
6+
"""
7+
8+
import logging
9+
import sys
10+
from typing import Any
11+
12+
import anyio
13+
import click
14+
from mcp.client.session import ClientSession
15+
from mcp.client.stdio import StdioServerParameters, stdio_client
16+
from mcp.server.stdio import stdio_server
17+
from mcp.shared.message import SessionMessage
18+
from mcp.shared.proxy import mcp_proxy
19+
20+
logging.basicConfig(level=logging.INFO, stream=sys.stderr)
21+
logger = logging.getLogger(__name__)
22+
23+
24+
async def inspect_client_message(msg: SessionMessage) -> SessionMessage | None:
25+
"""Inspect messages from the client before forwarding to the server."""
26+
root = msg.message.root
27+
if hasattr(root, "method"):
28+
logger.info(f"Client → Server: {root.method} (id: {getattr(root, 'id', 'N/A')})")
29+
elif hasattr(root, "result"):
30+
logger.info(f"Client → Server: Response (id: {getattr(root, 'id', 'N/A')})")
31+
elif hasattr(root, "error"):
32+
logger.info(f"Client → Server: Error (id: {getattr(root, 'id', 'N/A')})")
33+
return msg
34+
35+
36+
async def inspect_server_message(msg: SessionMessage) -> SessionMessage | None:
37+
"""Inspect messages from the server before forwarding to the client."""
38+
root = msg.message.root
39+
if hasattr(root, "method"):
40+
logger.info(f"Server → Client: {root.method} (id: {getattr(root, 'id', 'N/A')})")
41+
elif hasattr(root, "result"):
42+
logger.info(f"Server → Client: Response (id: {getattr(root, 'id', 'N/A')})")
43+
elif hasattr(root, "error"):
44+
logger.info(f"Server → Client: Error (id: {getattr(root, 'id', 'N/A')})")
45+
return msg
46+
47+
48+
def handle_error(error: Exception) -> None:
49+
"""Handle errors that occur during proxying."""
50+
logger.error(f"Proxy error: {error}", exc_info=True)
51+
52+
53+
@click.command()
54+
@click.option("--server-command", required=True, help="Command to start the MCP server")
55+
@click.option("--server-args", multiple=True, help="Arguments for the server command")
56+
@click.option("--inspect/--no-inspect", default=True, help="Enable message inspection logging")
57+
def main(server_command: str, server_args: tuple[str, ...], inspect: bool) -> int:
58+
"""
59+
MCP Proxy Server
60+
61+
This server acts as a proxy between an MCP client and an MCP server,
62+
forwarding messages bidirectionally while optionally logging/inspecting them.
63+
64+
Example usage:
65+
# Proxy to the simple-tool server with inspection
66+
mcp-simple-proxy --server-command uv --server-args run --server-args mcp-simple-tool
67+
68+
# Proxy without inspection
69+
mcp-simple-proxy --server-command uv --server-args run --server-args mcp-simple-tool --no-inspect
70+
"""
71+
logger.info(f"Starting MCP proxy to: {server_command} {' '.join(server_args)}")
72+
73+
async def run_proxy():
74+
# Set up connection to the backend server
75+
server_params = StdioServerParameters(
76+
command=server_command,
77+
args=list(server_args),
78+
)
79+
80+
# Connect to the backend server and set up stdio for client
81+
async with (
82+
stdio_client(server_params, errlog=sys.stderr) as server_streams,
83+
stdio_server() as client_streams,
84+
):
85+
logger.info("Proxy connections established")
86+
87+
# Run the proxy with optional inspection
88+
async with mcp_proxy(
89+
client_streams=client_streams,
90+
server_streams=server_streams,
91+
onerror=handle_error,
92+
on_client_message=inspect_client_message if inspect else None,
93+
on_server_message=inspect_server_message if inspect else None,
94+
):
95+
logger.info("Proxy is running. Press Ctrl+C to stop.")
96+
# Keep the proxy running until interrupted
97+
await anyio.sleep_forever()
98+
99+
try:
100+
anyio.run(run_proxy)
101+
except KeyboardInterrupt:
102+
logger.info("Proxy stopped by user")
103+
except Exception as e:
104+
logger.error(f"Proxy failed: {e}", exc_info=True)
105+
return 1
106+
107+
return 0
108+
109+
110+
if __name__ == "__main__":
111+
sys.exit(main())
112+
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
[project]
2+
name = "mcp-simple-proxy"
3+
version = "0.1.0"
4+
description = "A simple MCP proxy server that forwards messages between a client and server"
5+
readme = "README.md"
6+
requires-python = ">=3.10"
7+
authors = [{ name = "Anthropic, PBC." }]
8+
maintainers = [
9+
{ name = "David Soria Parra", email = "davidsp@anthropic.com" },
10+
{ name = "Justin Spahr-Summers", email = "justin@anthropic.com" },
11+
]
12+
keywords = ["mcp", "llm", "proxy", "gateway"]
13+
license = { text = "MIT" }
14+
classifiers = [
15+
"Development Status :: 4 - Beta",
16+
"Intended Audience :: Developers",
17+
"License :: OSI Approved :: MIT License",
18+
"Programming Language :: Python :: 3",
19+
"Programming Language :: Python :: 3.10",
20+
]
21+
dependencies = ["anyio>=4.5", "click>=8.2.0", "mcp"]
22+
23+
[project.scripts]
24+
mcp-simple-proxy = "mcp_simple_proxy.server:main"
25+
26+
[build-system]
27+
requires = ["hatchling"]
28+
build-backend = "hatchling.build"
29+
30+
[tool.hatch.build.targets.wheel]
31+
packages = ["mcp_simple_proxy"]
32+
33+
[tool.pyright]
34+
include = ["mcp_simple_proxy"]
35+
venvPath = "."
36+
venv = ".venv"
37+
38+
[tool.ruff.lint]
39+
select = ["E", "F", "I"]
40+
ignore = []
41+
42+
[tool.ruff]
43+
line-length = 120
44+
target-version = "py310"
45+
46+
[dependency-groups]
47+
dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"]
48+

src/mcp/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from .server.session import ServerSession
55
from .server.stdio import stdio_server
66
from .shared.exceptions import McpError, UrlElicitationRequiredError
7+
from .shared.proxy import mcp_proxy
78
from .types import (
89
CallToolRequest,
910
ClientCapabilities,
@@ -94,6 +95,7 @@
9495
"LoggingLevel",
9596
"LoggingMessageNotification",
9697
"McpError",
98+
"mcp_proxy",
9799
"Notification",
98100
"PingRequest",
99101
"ProgressNotification",

0 commit comments

Comments
 (0)