Merge feat/24: Implement MCP Streamable HTTP protocol endpoints

This commit is contained in:
2026-02-03 18:16:51 -05:00
3 changed files with 243 additions and 23 deletions

View File

@@ -159,31 +159,56 @@ GET /ping
Response: {"status": "healthy"} Response: {"status": "healthy"}
``` ```
#### List Tools #### MCP Protocol Endpoint
```bash
POST /tools/list
Response: { The server implements the MCP Streamable HTTP protocol:
"tools": [
{"name": "list_issues", "description": "...", "inputSchema": {...}},
...
]
}
```
#### Call Tool
```bash ```bash
POST /tools/call # Check protocol version
HEAD /mcp
# Send MCP JSON-RPC requests
POST /mcp
Content-Type: application/json Content-Type: application/json
Accept: application/json, text/event-stream
Authorization: Bearer YOUR_TOKEN # If auth enabled Authorization: Bearer YOUR_TOKEN # If auth enabled
# Example: Initialize
{ {
"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", "name": "list_issues",
"arguments": { "arguments": {
"owner": "myorg", "owner": "myorg",
"repo": "myrepo", "repo": "myrepo",
"state": "open" "state": "open"
} }
}
} }
``` ```
@@ -330,7 +355,7 @@ If tool filtering is not applied:
1. Check `.env` file syntax (no spaces around `=`) 1. Check `.env` file syntax (no spaces around `=`)
2. Verify comma-separated list format 2. Verify comma-separated list format
3. Check server logs for filter configuration 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 ## Security Considerations

View File

@@ -6,15 +6,23 @@ package and serves them over HTTP with authentication.
""" """
import logging import logging
import asyncio
from typing import Any
import uvicorn import uvicorn
from starlette.applications import Starlette from starlette.applications import Starlette
from starlette.responses import JSONResponse from starlette.responses import JSONResponse, Response
from starlette.routing import Route 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.config import load_settings
from gitea_mcp_remote.middleware import BearerAuthMiddleware, HealthCheckBypassMiddleware from gitea_mcp_remote.middleware import BearerAuthMiddleware, HealthCheckBypassMiddleware
from gitea_mcp_remote.filtering import ToolFilter 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__) logger = logging.getLogger(__name__)
@@ -23,6 +31,30 @@ async def health_check(request):
return JSONResponse({"status": "ok"}) 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(): def create_app():
"""Create the Starlette application with middleware.""" """Create the Starlette application with middleware."""
settings = load_settings() settings = load_settings()
@@ -38,12 +70,67 @@ def create_app():
if tool_filter.enabled_tools: if tool_filter.enabled_tools:
tool_names = list(tool_filter.enabled_tools) tool_names = list(tool_filter.enabled_tools)
# TODO: Issue #24 will add MCP protocol endpoints # Create MCP server with filtered tools
# from mcp_server import get_tool_definitions, create_tool_dispatcher, GiteaClient, GiteaConfig 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 = [ routes = [
Route("/health", health_check, methods=["GET"]), 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) app = Starlette(routes=routes)

108
tests/test_mcp_endpoints.py Normal file
View File

@@ -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