From 97a798df900c273747e8d71980a7727cc7e7deb4 Mon Sep 17 00:00:00 2001 From: Pavel Myasnov Date: Fri, 28 Nov 2025 13:40:23 +0000 Subject: [PATCH 1/2] Implement streamable http --- README.md | 25 ++++++++- index.py | 129 +++++++++++++++++++++++++++++++++++++++++++++-- requirements.txt | 1 + 3 files changed, 150 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index b669877..b0fdc79 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,8 @@ Create a `.env` file in the project root: ALLURE_TESTOPS_URL=https://your-allure-instance.com ALLURE_TOKEN=your-api-token PROJECT_ID=1 +MCP_TRANSPORT=stdio +MCP_ADDRESS=0.0.0.0:8000 ``` **Note:** Never commit `.env` files to version control. Use `.env.example` as a template. @@ -60,14 +62,35 @@ PROJECT_ID=1 ### Running the MCP Server -Run the server directly: +#### Stdio Mode (Default) + +Run the server in stdio mode (default): ```bash python index.py +# or explicitly: +python index.py --transport stdio ``` The server will run on stdio and communicate via the Model Context Protocol. +#### HTTP Streamable Mode + +Run the server in HTTP Streamable mode using environment variables: + +```bash +export MCP_TRANSPORT=streamable_http +export MCP_ADDRESS=0.0.0.0:8000 +python index.py +``` + +The server will be available at `http://localhost:8000` (or the specified address). + +### Environment Variables + +- `MCP_TRANSPORT`: Transport mode (`stdio` or `streamable_http`). Default: `stdio` +- `MCP_ADDRESS`: Host and port in format `host:port`. Default: `0.0.0.0:8000` + ### Standalone Scripts The repository includes utility scripts: diff --git a/index.py b/index.py index ce8c900..fad2583 100644 --- a/index.py +++ b/index.py @@ -11,6 +11,7 @@ from typing import Any, Dict, List from mcp.server import Server from mcp.server.stdio import stdio_server +from mcp.server.streamable_http import StreamableHTTPServerTransport from mcp.types import Tool, TextContent from allure_client import AllureClient, create_allure_client @@ -20,6 +21,8 @@ ALLURE_TESTOPS_URL = os.environ.get('ALLURE_TESTOPS_URL') ALLURE_TOKEN = os.environ.get('ALLURE_TOKEN') PROJECT_ID = os.environ.get('PROJECT_ID') +MCP_TRANSPORT = os.environ.get('MCP_TRANSPORT', 'stdio') # stdio or streamable_http +MCP_ADDRESS = os.environ.get('MCP_ADDRESS', '0.0.0.0:8000') # host:port # Validate required environment variables if not ALLURE_TOKEN: @@ -352,8 +355,8 @@ async def call_tool(name: str, arguments: Dict[str, Any]) -> List[TextContent]: error_message += f"\n{error.response.text}" return [TextContent(type="text", text=f"Error: {error_message}")] -async def main(): - """Main function to run the server""" +async def main_stdio(): + """Main function to run the server in stdio mode""" async with stdio_server() as (read_stream, write_stream): await server.run( read_stream, @@ -361,9 +364,127 @@ async def main(): server.create_initialization_options() ) +async def main_streamable_http(host: str, port: int): + """Main function to run the server in Streamable HTTP mode + + Note: Streamable HTTP transport is complex and requires proper session management. + For production use, consider using stdio transport or implementing full session lifecycle. + """ + import uvicorn + + # Single global transport instance + transport = StreamableHTTPServerTransport( + mcp_session_id=None, + is_json_response_enabled=False + ) + + # Track if server is initialized + server_initialized = asyncio.Event() + server_task = None + + async def init_mcp_server(): + """Initialize MCP server once at startup""" + async with transport.connect() as (read_stream, write_stream): + server_initialized.set() + await server.run( + read_stream, + write_stream, + server.create_initialization_options(), + stateless=True + ) + + async def asgi_app(scope, receive, send): + """ASGI application handler""" + nonlocal server_task + + if scope["type"] == "lifespan": + while True: + message = await receive() + if message["type"] == "lifespan.startup": + server_task = asyncio.create_task(init_mcp_server()) + await server_initialized.wait() + await send({"type": "lifespan.startup.complete"}) + elif message["type"] == "lifespan.shutdown": + if server_task: + server_task.cancel() + try: + await server_task + except asyncio.CancelledError: + pass + await send({"type": "lifespan.shutdown.complete"}) + return + + elif scope["type"] == "http": + path = scope.get("path", "") + + if path == "/health/liveness": + await send({ + "type": "http.response.start", + "status": 200, + "headers": [[b"content-type", b"application/json"]], + }) + await send({ + "type": "http.response.body", + "body": json.dumps({"status": "alive"}).encode("utf-8"), + }) + return + + elif path == "/health/readiness": + is_ready = server_initialized.is_set() + + status_code = 200 if is_ready else 503 + await send({ + "type": "http.response.start", + "status": status_code, + "headers": [[b"content-type", b"application/json"]], + }) + await send({ + "type": "http.response.body", + "body": json.dumps({ + "status": "ready" if is_ready else "not_ready", + "server_initialized": server_initialized.is_set(), + }).encode("utf-8"), + }) + return + + if not server_initialized.is_set(): + await server_initialized.wait() + + await transport.handle_request(scope, receive, send) + + config = uvicorn.Config( + app=asgi_app, + host=host, + port=port, + log_level="info", + lifespan="on" + ) + server_instance = uvicorn.Server(config) + await server_instance.serve() + + if __name__ == "__main__": - print("Allure TestOps MCP Server running on stdio", file=sys.stderr) + print(f"Allure TestOps MCP Server", file=sys.stderr) + print(f"Transport: {MCP_TRANSPORT}", file=sys.stderr) print(f"Connected to: {ALLURE_TESTOPS_URL}", file=sys.stderr) print(f"Project ID: {PROJECT_ID}", file=sys.stderr) print(f"Registered {len(all_tools)} tools", file=sys.stderr) - asyncio.run(main()) + + if MCP_TRANSPORT == 'stdio': + print("Running on stdio", file=sys.stderr) + asyncio.run(main_stdio()) + elif MCP_TRANSPORT == 'streamable_http': + # Parse host:port from MCP_ADDRESS + if ':' in MCP_ADDRESS: + host, port_str = MCP_ADDRESS.rsplit(':', 1) + port = int(port_str) + else: + host = MCP_ADDRESS + port = 8000 + print(f"Running on streamable_http at http://{host}:{port}", file=sys.stderr) + asyncio.run(main_streamable_http(host, port)) + else: + print(f"Error: Unknown transport mode: {MCP_TRANSPORT}", file=sys.stderr) + print("Supported modes: stdio, streamable_http", file=sys.stderr) + print("Set MCP_TRANSPORT environment variable to 'stdio' or 'streamable_http'", file=sys.stderr) + sys.exit(1) diff --git a/requirements.txt b/requirements.txt index f9692fb..59c8391 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ mcp>=1.0.0 httpx>=0.27.0 +uvicorn>=0.30.0 From 7f146b9a953e520d25b0cedac6a4207d7b18c68e Mon Sep 17 00:00:00 2001 From: Pavel Myasnov Date: Fri, 28 Nov 2025 14:47:20 +0000 Subject: [PATCH 2/2] Make PROJECT_ID parametrized on the request basis --- index.py | 45 ++++++++++++++++++++++++++++----------------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/index.py b/index.py index fad2583..06e3f4b 100644 --- a/index.py +++ b/index.py @@ -20,7 +20,6 @@ # Environment variables ALLURE_TESTOPS_URL = os.environ.get('ALLURE_TESTOPS_URL') ALLURE_TOKEN = os.environ.get('ALLURE_TOKEN') -PROJECT_ID = os.environ.get('PROJECT_ID') MCP_TRANSPORT = os.environ.get('MCP_TRANSPORT', 'stdio') # stdio or streamable_http MCP_ADDRESS = os.environ.get('MCP_ADDRESS', '0.0.0.0:8000') # host:port @@ -33,10 +32,6 @@ print('Error: ALLURE_TESTOPS_URL environment variable is required', file=sys.stderr) sys.exit(1) -if not PROJECT_ID: - print('Error: PROJECT_ID environment variable is required', file=sys.stderr) - sys.exit(1) - # Initialize Allure client allure_client = create_allure_client(ALLURE_TESTOPS_URL, ALLURE_TOKEN) @@ -48,9 +43,11 @@ inputSchema={ 'type': 'object', 'properties': { + 'project_id': {'type': 'string', 'description': 'Project ID'}, 'page': {'type': 'number', 'description': 'Page number (optional)'}, 'size': {'type': 'number', 'description': 'Page size (optional)'}, }, + 'required': ['project_id'], }, ), Tool( @@ -70,12 +67,13 @@ inputSchema={ 'type': 'object', 'properties': { + 'project_id': {'type': 'string', 'description': 'Project ID'}, 'name': {'type': 'string', 'description': 'Test case name'}, 'description': {'type': 'string', 'description': 'Test case description'}, 'status': {'type': 'string', 'description': 'Test case status'}, 'automated': {'type': 'boolean', 'description': 'Is automated'}, }, - 'required': ['name'], + 'required': ['project_id', 'name'], }, ), Tool( @@ -110,9 +108,10 @@ inputSchema={ 'type': 'object', 'properties': { + 'project_id': {'type': 'string', 'description': 'Project ID'}, 'csv_content': {'type': 'string', 'description': 'CSV file content'}, }, - 'required': ['csv_content'], + 'required': ['project_id', 'csv_content'], }, ), Tool( @@ -121,9 +120,11 @@ inputSchema={ 'type': 'object', 'properties': { + 'project_id': {'type': 'string', 'description': 'Project ID'}, 'page': {'type': 'number', 'description': 'Page number (optional)'}, 'size': {'type': 'number', 'description': 'Page size (optional)'}, }, + 'required': ['project_id'], }, ), Tool( @@ -143,10 +144,11 @@ inputSchema={ 'type': 'object', 'properties': { + 'project_id': {'type': 'string', 'description': 'Project ID'}, 'name': {'type': 'string', 'description': 'Launch name'}, 'closed': {'type': 'boolean', 'description': 'Is closed'}, }, - 'required': ['name'], + 'required': ['project_id', 'name'], }, ), Tool( @@ -190,9 +192,11 @@ inputSchema={ 'type': 'object', 'properties': { + 'project_id': {'type': 'string', 'description': 'Project ID'}, 'page': {'type': 'number', 'description': 'Page number (optional)'}, 'size': {'type': 'number', 'description': 'Page size (optional)'}, }, + 'required': ['project_id'], }, ), Tool( @@ -212,10 +216,11 @@ inputSchema={ 'type': 'object', 'properties': { + 'project_id': {'type': 'string', 'description': 'Project ID'}, 'name': {'type': 'string', 'description': 'Test plan name'}, 'description': {'type': 'string', 'description': 'Test plan description'}, }, - 'required': ['name'], + 'required': ['project_id', 'name'], }, ), Tool( @@ -258,7 +263,8 @@ async def call_tool(name: str, arguments: Dict[str, Any]) -> List[TextContent]: try: if name == 'list_test_cases': params = arguments or {} - result = await allure_client.get_test_cases(PROJECT_ID, params) + project_id = params.pop('project_id') + result = await allure_client.get_test_cases(project_id, params) return [TextContent(type="text", text=json.dumps(result, indent=2))] elif name == 'get_test_case': @@ -267,8 +273,9 @@ async def call_tool(name: str, arguments: Dict[str, Any]) -> List[TextContent]: return [TextContent(type="text", text=json.dumps(result, indent=2))] elif name == 'create_test_case': + project_id = arguments.pop('project_id') test_case = {k: v for k, v in arguments.items()} - result = await allure_client.create_test_case(PROJECT_ID, test_case) + result = await allure_client.create_test_case(project_id, test_case) return [TextContent(type="text", text=json.dumps(result, indent=2))] elif name == 'update_test_case': @@ -283,14 +290,16 @@ async def call_tool(name: str, arguments: Dict[str, Any]) -> List[TextContent]: return [TextContent(type="text", text=f"Test case {test_case_id} deleted successfully")] elif name == 'bulk_create_test_cases_from_csv': + project_id = arguments.get('project_id') csv_content = arguments.get('csv_content') test_cases = parse_test_cases_from_csv(csv_content) - results = await allure_client.bulk_create_test_cases(PROJECT_ID, test_cases) + results = await allure_client.bulk_create_test_cases(project_id, test_cases) return [TextContent(type="text", text=json.dumps(results, indent=2))] elif name == 'list_launches': params = arguments or {} - result = await allure_client.get_launches(PROJECT_ID, params) + project_id = params.pop('project_id') + result = await allure_client.get_launches(project_id, params) return [TextContent(type="text", text=json.dumps(result, indent=2))] elif name == 'get_launch': @@ -299,8 +308,9 @@ async def call_tool(name: str, arguments: Dict[str, Any]) -> List[TextContent]: return [TextContent(type="text", text=json.dumps(result, indent=2))] elif name == 'create_launch': + project_id = arguments.pop('project_id') launch = {k: v for k, v in arguments.items()} - result = await allure_client.create_launch(PROJECT_ID, launch) + result = await allure_client.create_launch(project_id, launch) return [TextContent(type="text", text=json.dumps(result, indent=2))] elif name == 'update_launch': @@ -321,7 +331,8 @@ async def call_tool(name: str, arguments: Dict[str, Any]) -> List[TextContent]: elif name == 'list_test_plans': params = arguments or {} - result = await allure_client.get_test_plans(PROJECT_ID, params) + project_id = params.pop('project_id') + result = await allure_client.get_test_plans(project_id, params) return [TextContent(type="text", text=json.dumps(result, indent=2))] elif name == 'get_test_plan': @@ -330,8 +341,9 @@ async def call_tool(name: str, arguments: Dict[str, Any]) -> List[TextContent]: return [TextContent(type="text", text=json.dumps(result, indent=2))] elif name == 'create_test_plan': + project_id = arguments.pop('project_id') test_plan = {k: v for k, v in arguments.items()} - result = await allure_client.create_test_plan(PROJECT_ID, test_plan) + result = await allure_client.create_test_plan(project_id, test_plan) return [TextContent(type="text", text=json.dumps(result, indent=2))] elif name == 'update_test_plan': @@ -467,7 +479,6 @@ async def asgi_app(scope, receive, send): print(f"Allure TestOps MCP Server", file=sys.stderr) print(f"Transport: {MCP_TRANSPORT}", file=sys.stderr) print(f"Connected to: {ALLURE_TESTOPS_URL}", file=sys.stderr) - print(f"Project ID: {PROJECT_ID}", file=sys.stderr) print(f"Registered {len(all_tools)} tools", file=sys.stderr) if MCP_TRANSPORT == 'stdio':