diff --git a/README.md b/README.md index ff59196..0c4287c 100644 --- a/README.md +++ b/README.md @@ -159,30 +159,55 @@ GET /ping Response: {"status": "healthy"} ``` -#### List Tools -```bash -POST /tools/list +#### MCP Protocol Endpoint -Response: { - "tools": [ - {"name": "list_issues", "description": "...", "inputSchema": {...}}, - ... - ] -} -``` +The server implements the MCP Streamable HTTP protocol: -#### Call Tool ```bash -POST /tools/call +# Check protocol version +HEAD /mcp + +# Send MCP JSON-RPC requests +POST /mcp Content-Type: application/json +Accept: application/json, text/event-stream Authorization: Bearer YOUR_TOKEN # If auth enabled +# Example: Initialize { - "name": "list_issues", - "arguments": { - "owner": "myorg", - "repo": "myrepo", - "state": "open" + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { + "name": "my-client", + "version": "1.0.0" + } + } +} + +# Example: List tools +{ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list", + "params": {} +} + +# Example: Call tool +{ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "list_issues", + "arguments": { + "owner": "myorg", + "repo": "myrepo", + "state": "open" + } } } ``` @@ -330,7 +355,7 @@ If tool filtering is not applied: 1. Check `.env` file syntax (no spaces around `=`) 2. Verify comma-separated list format 3. Check server logs for filter configuration -4. Query `POST /tools/list` to see filtered tools +4. Send `tools/list` MCP request to see filtered tools ## Security Considerations diff --git a/src/gitea_mcp_remote/server_http.py b/src/gitea_mcp_remote/server_http.py index 65e6c3d..476966c 100644 --- a/src/gitea_mcp_remote/server_http.py +++ b/src/gitea_mcp_remote/server_http.py @@ -6,15 +6,23 @@ package and serves them over HTTP with authentication. """ import logging +import asyncio +from typing import Any import uvicorn from starlette.applications import Starlette -from starlette.responses import JSONResponse -from starlette.routing import Route +from starlette.responses import JSONResponse, Response +from starlette.routing import Route, Mount +from mcp.server import Server +from mcp.server.streamable_http import StreamableHTTPServerTransport +from mcp.types import Tool, TextContent from gitea_mcp_remote.config import load_settings from gitea_mcp_remote.middleware import BearerAuthMiddleware, HealthCheckBypassMiddleware from gitea_mcp_remote.filtering import ToolFilter +# Import marketplace package +from mcp_server import get_tool_definitions, create_tool_dispatcher, GiteaClient + logger = logging.getLogger(__name__) @@ -23,6 +31,30 @@ async def health_check(request): return JSONResponse({"status": "ok"}) +def create_mcp_server(tool_names: list[str] | None = None) -> Server: + """Create and configure the MCP server with tools from the marketplace.""" + mcp_server = Server("gitea-mcp-remote") + + # Get tool definitions from marketplace + tools = get_tool_definitions(tool_filter=tool_names) + + # Create Gitea client and dispatcher + gitea_client = GiteaClient() + dispatcher = create_tool_dispatcher(gitea_client, tool_filter=tool_names) + + @mcp_server.list_tools() + async def list_tools() -> list[Tool]: + """Return available Gitea tools.""" + return tools + + @mcp_server.call_tool() + async def call_tool(name: str, arguments: dict) -> list[TextContent]: + """Execute a tool with the given arguments.""" + return await dispatcher(name, arguments) + + return mcp_server + + def create_app(): """Create the Starlette application with middleware.""" settings = load_settings() @@ -38,12 +70,67 @@ def create_app(): if tool_filter.enabled_tools: tool_names = list(tool_filter.enabled_tools) - # TODO: Issue #24 will add MCP protocol endpoints - # from mcp_server import get_tool_definitions, create_tool_dispatcher, GiteaClient, GiteaConfig + # Create MCP server with filtered tools + mcp_server = create_mcp_server(tool_names) + + # Store server for endpoint access + app_state = {"mcp_server": mcp_server} + + class MCPEndpoint: + """ASGI app wrapper for MCP protocol endpoint.""" + + async def __call__(self, scope, receive, send): + """Handle MCP requests - both POST and HEAD.""" + method = scope.get("method", "") + + if method == "HEAD": + # Return protocol version header + await send({ + "type": "http.response.start", + "status": 200, + "headers": [ + [b"x-mcp-protocol-version", b"2024-11-05"], + ], + }) + await send({ + "type": "http.response.body", + "body": b"", + }) + return + + # For POST requests, use the StreamableHTTPServerTransport + # Create transport for this request + transport = StreamableHTTPServerTransport(mcp_session_id=None) + + # Run the MCP server with this transport + async with transport.connect() as (read_stream, write_stream): + # Start server task + server_task = asyncio.create_task( + app_state["mcp_server"].run( + read_stream, + write_stream, + app_state["mcp_server"].create_initialization_options() + ) + ) + + # Handle the HTTP request + try: + await transport.handle_request(scope, receive, send) + finally: + # Cancel server task if still running + if not server_task.done(): + server_task.cancel() + try: + await server_task + except asyncio.CancelledError: + pass + + # Create MCP endpoint instance + mcp_endpoint = MCPEndpoint() routes = [ Route("/health", health_check, methods=["GET"]), - # MCP endpoints will be added in Issue #24 + Route("/mcp", mcp_endpoint, methods=["GET", "POST", "HEAD"]), ] app = Starlette(routes=routes) diff --git a/tests/test_mcp_endpoints.py b/tests/test_mcp_endpoints.py new file mode 100644 index 0000000..f1be6ae --- /dev/null +++ b/tests/test_mcp_endpoints.py @@ -0,0 +1,108 @@ +"""Tests for MCP protocol endpoints.""" +import pytest +import json +import re +from starlette.testclient import TestClient +from gitea_mcp_remote.server_http import create_app +from unittest.mock import patch + + +@pytest.fixture +def mock_env(): + """Mock environment variables for testing.""" + env = { + "GITEA_URL": "https://gitea.example.com", + "GITEA_TOKEN": "test_token", + "GITEA_OWNER": "test_owner", + } + with patch.dict("os.environ", env): + yield env + + +@pytest.fixture +def client(mock_env): + """Create test client.""" + app = create_app() + return TestClient(app) + + +def parse_sse_message(sse_text: str) -> dict: + """Parse SSE message data.""" + data_match = re.search(r'data: (.+)', sse_text) + if data_match: + return json.loads(data_match.group(1)) + return None + + +def test_mcp_head_endpoint(client): + """Test HEAD /mcp returns protocol version header.""" + response = client.head("/mcp") + assert response.status_code == 200 + assert "x-mcp-protocol-version" in response.headers + assert response.headers["x-mcp-protocol-version"] == "2024-11-05" + + +def test_mcp_initialize(client): + """Test MCP initialize request.""" + initialize_request = { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { + "name": "test-client", + "version": "1.0.0" + } + } + } + + response = client.post( + "/mcp", + json=initialize_request, + headers={ + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream" + } + ) + + assert response.status_code == 200 + + # Parse SSE response + data = parse_sse_message(response.text) + assert data is not None + assert data.get("jsonrpc") == "2.0" + assert "result" in data + assert data["result"].get("protocolVersion") == "2024-11-05" + assert "serverInfo" in data["result"] + assert data["result"]["serverInfo"]["name"] == "gitea-mcp-remote" + + +def test_mcp_missing_accept_header(client): + """Test MCP request without required Accept header.""" + initialize_request = { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { + "name": "test-client", + "version": "1.0.0" + } + } + } + + response = client.post( + "/mcp", + json=initialize_request, + headers={ + "Content-Type": "application/json", + "Accept": "application/json" # Missing text/event-stream + } + ) + + # Should return error about missing accept header + assert response.status_code == 406