From 6cf925e99d0364854a348ea79acbf2aef90bc130 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 29 Dec 2024 19:27:57 -0800 Subject: [PATCH 01/11] Add library for serving an sse server proxying a stdio server --- pyproject.toml | 5 ++- src/mcp_proxy/sse_server.py | 81 +++++++++++++++++++++++++++++++++++++ tests/test_sse_server.py | 54 +++++++++++++++++++++++++ uv.lock | 31 +++++++++++++- 4 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 src/mcp_proxy/sse_server.py create mode 100644 tests/test_sse_server.py diff --git a/pyproject.toml b/pyproject.toml index 9058cb6..354c062 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,10 @@ classifiers = [ ] version = "0.2.2" requires-python = ">=3.11" -dependencies = ["mcp"] +dependencies = [ + "mcp", + "uvicorn>=0.34.0", +] [build-system] requires = ["setuptools"] diff --git a/src/mcp_proxy/sse_server.py b/src/mcp_proxy/sse_server.py new file mode 100644 index 0000000..5197979 --- /dev/null +++ b/src/mcp_proxy/sse_server.py @@ -0,0 +1,81 @@ +"""Create a local SSE server that proxies requests to a stdio MCP server.""" + +from dataclasses import dataclass +from typing import Literal + +import uvicorn +from mcp.client.session import ClientSession +from mcp.client.stdio import StdioServerParameters, stdio_client +from mcp.server import Server +from mcp.server.sse import SseServerTransport +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.routing import Route + +from . import create_proxy_server + + +@dataclass +class SseServerSettings: + """Settings for the server.""" + + host: str = "0.0.0.0:" + post: int = 8000 + log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO" + + +def create_starlette_app(mcp_server: Server, debug: bool | None = None) -> Starlette: + """Create a Starlette application that can server the provied mcp server with SSE.""" + sse = SseServerTransport("/messages") + + async def handle_sse(request: Request) -> None: + async with sse.connect_sse( + request.scope, + request.receive, + request._send, # noqa: SLF001 + ) as (read_stream, write_stream): + await mcp_server.run( + read_stream, + write_stream, + mcp_server.create_initialization_options(), + ) + + async def handle_messages(request: Request) -> None: + await sse.handle_post_message(request.scope, request.receive, request._send) # noqa: SLF001 + + return Starlette( + debug=debug, + routes=[ + Route("/sse", endpoint=handle_sse), + Route("/messages", endpoint=handle_messages, methods=["POST"]), + ], + ) + + +async def run_sse_server( + stdio_params: StdioServerParameters, + sse_settings: SseServerSettings, +) -> None: + """Run the stdio client and expose an SSE server. + + Args: + ---- + stdio_params: The parameters for the stdio client that spawns a stdio server. + sse_settings: The settings for the SSE server that accepts incoming requests. + + """ + async with stdio_client(stdio_params) as streams, ClientSession(*streams) as session: + mcp_server = await create_proxy_server(session) + + # Bind SSE request handling to MCP server + starlette_app = await create_starlette_app(mcp_server, sse_settings.log_level == "DEBUG") + + # Configure HTTP server + config = uvicorn.Config( + starlette_app, + host=sse_settings.host, + port=sse_settings.port, + log_level=sse_settings.log_level.lower(), + ) + http_server = uvicorn.Server(config) + await http_server.serve() diff --git a/tests/test_sse_server.py b/tests/test_sse_server.py new file mode 100644 index 0000000..dd16166 --- /dev/null +++ b/tests/test_sse_server.py @@ -0,0 +1,54 @@ +"""Tests for the sse server.""" + +import asyncio +from collections.abc import Generator +from contextlib import asynccontextmanager + +import uvicorn +from mcp import types +from mcp.client.session import ClientSession +from mcp.client.sse import sse_client +from mcp.server import Server +from starlette.applications import Starlette + +from mcp_proxy.sse_server import create_starlette_app + + +@asynccontextmanager +async def start_http_server(app: Starlette) -> Generator[str]: + """Start an http server in the background and return a url to connect to it. + + This sets port=0 to pick a free available port to avoid collisions. We don't + have an explicit way to be notified when the server is ready so poll until + we can grab the port number. + """ + config = uvicorn.Config(app, port=0, log_level="info") + server = uvicorn.Server(config) + task = asyncio.create_task(server.serve()) + while not server.started: # noqa: ASYNC110 + await asyncio.sleep(0.01) + hostport = next( + iter([socket.getsockname() for server in server.servers for socket in server.sockets]), + ) + yield f"http://{hostport[0]}:{hostport[1]}" + task.cancel() + server.shutdown() + + +async def test_create_starlette_app() -> None: + """Test basic glue code for the SSE transport and a fake MCP server.""" + server = Server("prompt-server") + + @server.list_prompts() + async def list_prompts() -> list[types.Prompt]: + return [types.Prompt(name="prompt1")] + + app = create_starlette_app(server) + + async with start_http_server(app) as url: + mcp_url = f"{url}/sse" + async with sse_client(url=mcp_url) as streams, ClientSession(*streams) as session: + await session.initialize() + response = await session.list_prompts() + assert len(response.prompts) == 1 + assert response.prompts[0].name == "prompt1" diff --git a/uv.lock b/uv.lock index 91afb04..a6ea7cf 100644 --- a/uv.lock +++ b/uv.lock @@ -33,6 +33,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a5/32/8f6669fc4798494966bf446c8c4a162e0b5d893dff088afddf76414f70e1/certifi-2024.12.14-py3-none-any.whl", hash = "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56", size = 164927 }, ] +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "platform_system == 'Windows'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -177,6 +189,7 @@ version = "0.2.2" source = { editable = "." } dependencies = [ { name = "mcp" }, + { name = "uvicorn" }, ] [package.dev-dependencies] @@ -187,7 +200,10 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "mcp" }] +requires-dist = [ + { name = "mcp" }, + { name = "uvicorn", specifier = ">=0.34.0" }, +] [package.metadata.requires-dev] dev = [ @@ -350,3 +366,16 @@ sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec3 wheels = [ { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, ] + +[[package]] +name = "uvicorn" +version = "0.34.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/4d/938bd85e5bf2edeec766267a5015ad969730bb91e31b44021dfe8b22df6c/uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9", size = 76568 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/14/33a3a1352cfa71812a3a21e8c9bfb83f60b0011f5e36f2b1399d51928209/uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4", size = 62315 }, +] From 7ea5edde618f47425d55e4f657ddc1743120277b Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 30 Dec 2024 09:14:30 -0800 Subject: [PATCH 02/11] Change context manager for running server in the background thread --- tests/test_sse_server.py | 53 ++++++++++++++++++++++++---------------- 1 file changed, 32 insertions(+), 21 deletions(-) diff --git a/tests/test_sse_server.py b/tests/test_sse_server.py index dd16166..29912cc 100644 --- a/tests/test_sse_server.py +++ b/tests/test_sse_server.py @@ -2,7 +2,9 @@ import asyncio from collections.abc import Generator -from contextlib import asynccontextmanager +import contextlib +import threading +import time import uvicorn from mcp import types @@ -14,25 +16,32 @@ from mcp_proxy.sse_server import create_starlette_app -@asynccontextmanager -async def start_http_server(app: Starlette) -> Generator[str]: - """Start an http server in the background and return a url to connect to it. +class BackgroundServer(uvicorn.Server): + """A test server that runs in a background thread.""" - This sets port=0 to pick a free available port to avoid collisions. We don't - have an explicit way to be notified when the server is ready so poll until - we can grab the port number. - """ - config = uvicorn.Config(app, port=0, log_level="info") - server = uvicorn.Server(config) - task = asyncio.create_task(server.serve()) - while not server.started: # noqa: ASYNC110 - await asyncio.sleep(0.01) - hostport = next( - iter([socket.getsockname() for server in server.servers for socket in server.sockets]), - ) - yield f"http://{hostport[0]}:{hostport[1]}" - task.cancel() - server.shutdown() + def install_signal_handlers(self): + """Do not install signal handlers.""" + pass + + @contextlib.asynccontextmanager + async def run_in_background(self): + """Run the server in a background thread.""" + task = asyncio.create_task(self.serve()) + try: + while not self.started: + await asyncio.sleep(1e-3) + yield + finally: + task.cancel() + self.shutdown() + + @property + def url(self): + """Return the url of the started server.""" + hostport = next( + iter([socket.getsockname() for server in self.servers for socket in server.sockets]), + ) + return f"http://{hostport[0]}:{hostport[1]}" async def test_create_starlette_app() -> None: @@ -45,8 +54,10 @@ async def list_prompts() -> list[types.Prompt]: app = create_starlette_app(server) - async with start_http_server(app) as url: - mcp_url = f"{url}/sse" + config = uvicorn.Config(app, port=0, log_level="info") + server = BackgroundServer(config) + async with server.run_in_background(): + mcp_url = f"{server.url}/sse" async with sse_client(url=mcp_url) as streams, ClientSession(*streams) as session: await session.initialize() response = await session.list_prompts() From efb673a0af0ac24fcfc71da141ddf5997ec43373 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 30 Dec 2024 09:19:24 -0800 Subject: [PATCH 03/11] Fix lint errors in new test fixture --- tests/test_sse_server.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/tests/test_sse_server.py b/tests/test_sse_server.py index 29912cc..291ef8f 100644 --- a/tests/test_sse_server.py +++ b/tests/test_sse_server.py @@ -1,17 +1,13 @@ """Tests for the sse server.""" import asyncio -from collections.abc import Generator import contextlib -import threading -import time import uvicorn from mcp import types from mcp.client.session import ClientSession from mcp.client.sse import sse_client from mcp.server import Server -from starlette.applications import Starlette from mcp_proxy.sse_server import create_starlette_app @@ -19,16 +15,15 @@ class BackgroundServer(uvicorn.Server): """A test server that runs in a background thread.""" - def install_signal_handlers(self): + def install_signal_handlers(self) -> None: """Do not install signal handlers.""" - pass @contextlib.asynccontextmanager - async def run_in_background(self): + async def run_in_background(self) -> None: """Run the server in a background thread.""" task = asyncio.create_task(self.serve()) try: - while not self.started: + while not self.started: # noqa: ASYNC110 await asyncio.sleep(1e-3) yield finally: @@ -36,7 +31,7 @@ async def run_in_background(self): self.shutdown() @property - def url(self): + def url(self) -> str: """Return the url of the started server.""" hostport = next( iter([socket.getsockname() for server in self.servers for socket in server.sockets]), From a9835c554ddef40d510768bed85a1d02c6812d89 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 30 Dec 2024 09:23:13 -0800 Subject: [PATCH 04/11] Update starlette response routing --- src/mcp_proxy/sse_server.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/mcp_proxy/sse_server.py b/src/mcp_proxy/sse_server.py index 5197979..fb5993c 100644 --- a/src/mcp_proxy/sse_server.py +++ b/src/mcp_proxy/sse_server.py @@ -10,7 +10,7 @@ from mcp.server.sse import SseServerTransport from starlette.applications import Starlette from starlette.requests import Request -from starlette.routing import Route +from starlette.routing import Route, Mount from . import create_proxy_server @@ -26,7 +26,7 @@ class SseServerSettings: def create_starlette_app(mcp_server: Server, debug: bool | None = None) -> Starlette: """Create a Starlette application that can server the provied mcp server with SSE.""" - sse = SseServerTransport("/messages") + sse = SseServerTransport("/messages/") async def handle_sse(request: Request) -> None: async with sse.connect_sse( @@ -40,14 +40,11 @@ async def handle_sse(request: Request) -> None: mcp_server.create_initialization_options(), ) - async def handle_messages(request: Request) -> None: - await sse.handle_post_message(request.scope, request.receive, request._send) # noqa: SLF001 - return Starlette( debug=debug, routes=[ Route("/sse", endpoint=handle_sse), - Route("/messages", endpoint=handle_messages, methods=["POST"]), + Mount("/messages/", app=sse.handle_post_message) ], ) From 1210203ccd79c24dd55712bd938d495145950c52 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 30 Dec 2024 09:32:58 -0800 Subject: [PATCH 05/11] Fix ruff format errors --- src/mcp_proxy/sse_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mcp_proxy/sse_server.py b/src/mcp_proxy/sse_server.py index fb5993c..d451c6d 100644 --- a/src/mcp_proxy/sse_server.py +++ b/src/mcp_proxy/sse_server.py @@ -44,7 +44,7 @@ async def handle_sse(request: Request) -> None: debug=debug, routes=[ Route("/sse", endpoint=handle_sse), - Mount("/messages/", app=sse.handle_post_message) + Mount("/messages/", app=sse.handle_post_message), ], ) From b2dc33108d71a3279b0e995917bab4822638610c Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 30 Dec 2024 09:33:01 -0800 Subject: [PATCH 06/11] Fix ruff format errors --- src/mcp_proxy/sse_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mcp_proxy/sse_server.py b/src/mcp_proxy/sse_server.py index d451c6d..e470acc 100644 --- a/src/mcp_proxy/sse_server.py +++ b/src/mcp_proxy/sse_server.py @@ -10,7 +10,7 @@ from mcp.server.sse import SseServerTransport from starlette.applications import Starlette from starlette.requests import Request -from starlette.routing import Route, Mount +from starlette.routing import Mount, Route from . import create_proxy_server From 5cfa7d0aaf570e78f2f53b3332fc542b342bfde9 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 31 Dec 2024 07:09:18 -0800 Subject: [PATCH 07/11] Fix typos in SseServerSettigs --- src/mcp_proxy/sse_server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/mcp_proxy/sse_server.py b/src/mcp_proxy/sse_server.py index e470acc..3583922 100644 --- a/src/mcp_proxy/sse_server.py +++ b/src/mcp_proxy/sse_server.py @@ -19,8 +19,8 @@ class SseServerSettings: """Settings for the server.""" - host: str = "0.0.0.0:" - post: int = 8000 + host: str = "0.0.0.0" + port: int = 8000 log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO" From 5657f5d438a307abf5ffac885cece19c057d4ee8 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 31 Dec 2024 07:15:39 -0800 Subject: [PATCH 08/11] Rename host to bind host and update to localhost --- src/mcp_proxy/sse_server.py | 4 ++-- tests/test_sse_server.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/mcp_proxy/sse_server.py b/src/mcp_proxy/sse_server.py index 3583922..f4cb46b 100644 --- a/src/mcp_proxy/sse_server.py +++ b/src/mcp_proxy/sse_server.py @@ -19,7 +19,7 @@ class SseServerSettings: """Settings for the server.""" - host: str = "0.0.0.0" + bind_host: str = "127.0.0.1" port: int = 8000 log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO" @@ -70,7 +70,7 @@ async def run_sse_server( # Configure HTTP server config = uvicorn.Config( starlette_app, - host=sse_settings.host, + host=sse_settings.bind_host, port=sse_settings.port, log_level=sse_settings.log_level.lower(), ) diff --git a/tests/test_sse_server.py b/tests/test_sse_server.py index 291ef8f..4f3d83f 100644 --- a/tests/test_sse_server.py +++ b/tests/test_sse_server.py @@ -3,11 +3,11 @@ import asyncio import contextlib -import uvicorn from mcp import types from mcp.client.session import ClientSession from mcp.client.sse import sse_client from mcp.server import Server +import uvicorn from mcp_proxy.sse_server import create_starlette_app From 78d8d92acf154fc8aa744ecead8dcad41f035298 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 31 Dec 2024 07:21:31 -0800 Subject: [PATCH 09/11] Update for new import location --- src/mcp_proxy/sse_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mcp_proxy/sse_server.py b/src/mcp_proxy/sse_server.py index f4cb46b..2184e0f 100644 --- a/src/mcp_proxy/sse_server.py +++ b/src/mcp_proxy/sse_server.py @@ -12,7 +12,7 @@ from starlette.requests import Request from starlette.routing import Mount, Route -from . import create_proxy_server +from .proxy_server import create_proxy_server @dataclass From cae49f5f6d92577788029106d667599cdc42f571 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 31 Dec 2024 07:23:23 -0800 Subject: [PATCH 10/11] Update imports based on ruff rules --- tests/test_sse_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_sse_server.py b/tests/test_sse_server.py index 4f3d83f..291ef8f 100644 --- a/tests/test_sse_server.py +++ b/tests/test_sse_server.py @@ -3,11 +3,11 @@ import asyncio import contextlib +import uvicorn from mcp import types from mcp.client.session import ClientSession from mcp.client.sse import sse_client from mcp.server import Server -import uvicorn from mcp_proxy.sse_server import create_starlette_app From 255823c07d656aa888c7978745fcadc35237b7a0 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 31 Dec 2024 07:35:10 -0800 Subject: [PATCH 11/11] Update src/mcp_proxy/sse_server.py Co-authored-by: Guillaume Raille --- src/mcp_proxy/sse_server.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/mcp_proxy/sse_server.py b/src/mcp_proxy/sse_server.py index 2184e0f..7be2350 100644 --- a/src/mcp_proxy/sse_server.py +++ b/src/mcp_proxy/sse_server.py @@ -56,7 +56,6 @@ async def run_sse_server( """Run the stdio client and expose an SSE server. Args: - ---- stdio_params: The parameters for the stdio client that spawns a stdio server. sse_settings: The settings for the SSE server that accepts incoming requests.