feat: support --debug argument for verbose output (#47)

Closes #34
This commit is contained in:
Sergey Parfenyuk
2025-04-13 11:55:10 +02:00
committed by GitHub
parent 73e90cb649
commit 357c8c23f0
6 changed files with 95 additions and 9 deletions

11
.vscode/launch.json vendored
View File

@@ -6,6 +6,17 @@
"type": "python", "type": "python",
"request": "test", "request": "test",
"justMyCode": false "justMyCode": false
},
{
"name": "Debug mcp-proxy",
"type": "debugpy",
"request": "launch",
"console": "integratedTerminal",
"justMyCode": false,
"python": "${command:python.interpreterPath}",
"envFile": "${workspaceFolder}/.env",
"module": "mcp_proxy",
"args": ["--sse-port=8080", "--debug", "--", "uvx", "mcp-server-fetch"]
} }
] ]
} }

View File

@@ -260,6 +260,7 @@ stdio client options:
Environment variables used when spawning the server. Can be used multiple times. Environment variables used when spawning the server. Can be used multiple times.
--pass-environment, --no-pass-environment --pass-environment, --no-pass-environment
Pass through all environment variables when spawning the server. Pass through all environment variables when spawning the server.
--debug, --no-debug Enable debug mode with detailed logging output.
SSE server options: SSE server options:
--sse-port SSE_PORT Port to expose an SSE server on. Default is a random port --sse-port SSE_PORT Port to expose an SSE server on. Default is a random port

View File

@@ -18,7 +18,6 @@ from mcp.client.stdio import StdioServerParameters
from .sse_client import run_sse_client from .sse_client import run_sse_client
from .sse_server import SseServerSettings, run_sse_server from .sse_server import SseServerSettings, run_sse_server
logging.basicConfig(level=logging.DEBUG)
SSE_URL: t.Final[str | None] = os.getenv( SSE_URL: t.Final[str | None] = os.getenv(
"SSE_URL", "SSE_URL",
None, None,
@@ -84,6 +83,12 @@ def main() -> None:
help="Pass through all environment variables when spawning the server.", help="Pass through all environment variables when spawning the server.",
default=False, default=False,
) )
stdio_client_options.add_argument(
"--debug",
action=argparse.BooleanOptionalAction,
help="Enable debug mode with detailed logging output.",
default=False,
)
sse_server_group = parser.add_argument_group("SSE server options") sse_server_group = parser.add_argument_group("SSE server options")
sse_server_group.add_argument( sse_server_group.add_argument(
@@ -110,13 +115,16 @@ def main() -> None:
parser.print_help() parser.print_help()
sys.exit(1) sys.exit(1)
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
logger = logging.getLogger(__name__)
if ( if (
SSE_URL SSE_URL
or args.command_or_url.startswith("http://") or args.command_or_url.startswith("http://")
or args.command_or_url.startswith("https://") or args.command_or_url.startswith("https://")
): ):
# Start a client connected to the SSE server, and expose as a stdio server # Start a client connected to the SSE server, and expose as a stdio server
logging.debug("Starting SSE client and stdio server") logger.debug("Starting SSE client and stdio server")
headers = dict(args.headers) headers = dict(args.headers)
if api_access_token := os.getenv("API_ACCESS_TOKEN", None): if api_access_token := os.getenv("API_ACCESS_TOKEN", None):
headers["Authorization"] = f"Bearer {api_access_token}" headers["Authorization"] = f"Bearer {api_access_token}"
@@ -124,7 +132,7 @@ def main() -> None:
return return
# Start a client connected to the given command, and expose as an SSE server # Start a client connected to the given command, and expose as an SSE server
logging.debug("Starting stdio client and SSE server") logger.debug("Starting stdio client and SSE server")
# The environment variables passed to the server process # The environment variables passed to the server process
env: dict[str, str] = {} env: dict[str, str] = {}
@@ -143,6 +151,7 @@ def main() -> None:
bind_host=args.sse_host, bind_host=args.sse_host,
port=args.sse_port, port=args.sse_port,
allow_origins=args.allow_origin if len(args.allow_origin) > 0 else None, allow_origins=args.allow_origin if len(args.allow_origin) > 0 else None,
log_level="DEBUG" if args.debug else "INFO",
) )
asyncio.run(run_sse_server(stdio_params, sse_settings)) asyncio.run(run_sse_server(stdio_params, sse_settings))

View File

@@ -3,20 +3,26 @@
This server is created independent of any transport mechanism. This server is created independent of any transport mechanism.
""" """
import logging
import typing as t import typing as t
from mcp import server, types from mcp import server, types
from mcp.client.session import ClientSession from mcp.client.session import ClientSession
logger = logging.getLogger(__name__)
async def create_proxy_server(remote_app: ClientSession) -> server.Server[object]: # noqa: C901
async def create_proxy_server(remote_app: ClientSession) -> server.Server[object]: # noqa: C901, PLR0915
"""Create a server instance from a remote app.""" """Create a server instance from a remote app."""
logger.debug("Sending initalization request to remote MCP server...")
response = await remote_app.initialize() response = await remote_app.initialize()
capabilities = response.capabilities capabilities = response.capabilities
logger.debug("Configuring proxied MCP server...")
app: server.Server[object] = server.Server(name=response.serverInfo.name) app: server.Server[object] = server.Server(name=response.serverInfo.name)
if capabilities.prompts: if capabilities.prompts:
logger.debug("Capabilities: adding Prompts...")
async def _list_prompts(_: t.Any) -> types.ServerResult: # noqa: ANN401 async def _list_prompts(_: t.Any) -> types.ServerResult: # noqa: ANN401
result = await remote_app.list_prompts() result = await remote_app.list_prompts()
@@ -31,6 +37,7 @@ async def create_proxy_server(remote_app: ClientSession) -> server.Server[object
app.request_handlers[types.GetPromptRequest] = _get_prompt app.request_handlers[types.GetPromptRequest] = _get_prompt
if capabilities.resources: if capabilities.resources:
logger.debug("Capabilities: adding Resources...")
async def _list_resources(_: t.Any) -> types.ServerResult: # noqa: ANN401 async def _list_resources(_: t.Any) -> types.ServerResult: # noqa: ANN401
result = await remote_app.list_resources() result = await remote_app.list_resources()
@@ -38,12 +45,11 @@ async def create_proxy_server(remote_app: ClientSession) -> server.Server[object
app.request_handlers[types.ListResourcesRequest] = _list_resources app.request_handlers[types.ListResourcesRequest] = _list_resources
# list_resource_templates() is not implemented in the client async def _list_resource_templates(_: t.Any) -> types.ServerResult: # noqa: ANN401
# async def _list_resource_templates(_: t.Any) -> types.ServerResult: result = await remote_app.list_resource_templates()
# result = await remote_app.list_resource_templates() return types.ServerResult(result)
# return types.ServerResult(result)
# app.request_handlers[types.ListResourceTemplatesRequest] = _list_resource_templates app.request_handlers[types.ListResourceTemplatesRequest] = _list_resource_templates
async def _read_resource(req: types.ReadResourceRequest) -> types.ServerResult: async def _read_resource(req: types.ReadResourceRequest) -> types.ServerResult:
result = await remote_app.read_resource(req.params.uri) result = await remote_app.read_resource(req.params.uri)
@@ -52,6 +58,7 @@ async def create_proxy_server(remote_app: ClientSession) -> server.Server[object
app.request_handlers[types.ReadResourceRequest] = _read_resource app.request_handlers[types.ReadResourceRequest] = _read_resource
if capabilities.logging: if capabilities.logging:
logger.debug("Capabilities: adding Logging...")
async def _set_logging_level(req: types.SetLevelRequest) -> types.ServerResult: async def _set_logging_level(req: types.SetLevelRequest) -> types.ServerResult:
await remote_app.set_logging_level(req.params.level) await remote_app.set_logging_level(req.params.level)
@@ -60,6 +67,7 @@ async def create_proxy_server(remote_app: ClientSession) -> server.Server[object
app.request_handlers[types.SetLevelRequest] = _set_logging_level app.request_handlers[types.SetLevelRequest] = _set_logging_level
if capabilities.resources: if capabilities.resources:
logger.debug("Capabilities: adding Resources...")
async def _subscribe_resource(req: types.SubscribeRequest) -> types.ServerResult: async def _subscribe_resource(req: types.SubscribeRequest) -> types.ServerResult:
await remote_app.subscribe_resource(req.params.uri) await remote_app.subscribe_resource(req.params.uri)
@@ -74,6 +82,7 @@ async def create_proxy_server(remote_app: ClientSession) -> server.Server[object
app.request_handlers[types.UnsubscribeRequest] = _unsubscribe_resource app.request_handlers[types.UnsubscribeRequest] = _unsubscribe_resource
if capabilities.tools: if capabilities.tools:
logger.debug("Capabilities: adding Tools...")
async def _list_tools(_: t.Any) -> types.ServerResult: # noqa: ANN401 async def _list_tools(_: t.Any) -> types.ServerResult: # noqa: ANN401
tools = await remote_app.list_tools() tools = await remote_app.list_tools()

View File

@@ -1,5 +1,6 @@
"""Create a local SSE server that proxies requests to a stdio MCP server.""" """Create a local SSE server that proxies requests to a stdio MCP server."""
import logging
from dataclasses import dataclass from dataclasses import dataclass
from typing import Literal from typing import Literal
@@ -16,6 +17,8 @@ from starlette.routing import Mount, Route
from .proxy_server import create_proxy_server from .proxy_server import create_proxy_server
logger = logging.getLogger(__name__)
@dataclass @dataclass
class SseServerSettings: class SseServerSettings:
@@ -81,6 +84,7 @@ async def run_sse_server(
""" """
async with stdio_client(stdio_params) as streams, ClientSession(*streams) as session: async with stdio_client(stdio_params) as streams, ClientSession(*streams) as session:
logger.debug("Starting proxy server...")
mcp_server = await create_proxy_server(session) mcp_server = await create_proxy_server(session)
# Bind SSE request handling to MCP server # Bind SSE request handling to MCP server
@@ -98,4 +102,9 @@ async def run_sse_server(
log_level=sse_settings.log_level.lower(), log_level=sse_settings.log_level.lower(),
) )
http_server = uvicorn.Server(config) http_server = uvicorn.Server(config)
logger.debug(
"Serving incoming requests on %s:%s",
sse_settings.bind_host,
sse_settings.port,
)
await http_server.serve() await http_server.serve()

View File

@@ -110,6 +110,20 @@ def server_can_list_resources(server: Server[object], resource: types.Resource)
return server return server
@pytest.fixture
def server_can_list_resource_templates(
server_can_list_resources: Server[object],
resource_template: types.ResourceTemplate,
) -> Server[object]:
"""Return a server instance with resources."""
@server_can_list_resources.list_resource_templates() # type: ignore[no-untyped-call,misc]
async def _() -> list[types.ResourceTemplate]:
return [resource_template]
return server_can_list_resources
@pytest.fixture @pytest.fixture
def server_can_subscribe_resource( def server_can_subscribe_resource(
server_can_list_resources: Server[object], server_can_list_resources: Server[object],
@@ -307,6 +321,39 @@ async def test_list_resources(
assert list_resources_result.resources == [resource] assert list_resources_result.resources == [resource]
@pytest.mark.parametrize(
"resource",
[
types.Resource(
uri=AnyUrl("scheme://resource-uri"),
name="resource-name",
description="resource-description",
),
],
)
@pytest.mark.parametrize(
"resource_template",
[
types.ResourceTemplate(
uriTemplate="scheme://resource-uri/{resource}",
name="resource-name",
description="resource-description",
),
],
)
async def test_list_resource_templates(
session_generator: SessionContextManager,
server_can_list_resource_templates: Server[object],
resource_template: types.ResourceTemplate,
) -> None:
"""Test get_resource."""
async with session_generator(server_can_list_resource_templates) as session:
await session.initialize()
list_resources_result = await session.list_resource_templates()
assert list_resources_result.resourceTemplates == [resource_template]
@pytest.mark.parametrize("prompt_callback", [AsyncMock()]) @pytest.mark.parametrize("prompt_callback", [AsyncMock()])
@pytest.mark.parametrize("prompt", [types.Prompt(name="prompt1")]) @pytest.mark.parametrize("prompt", [types.Prompt(name="prompt1")])
async def test_get_prompt( async def test_get_prompt(